feat(ui-lvgl): LVGL 9 skeleton frontend for Wio Tracker L2

New examples/companion_radio/ui-lvgl/ over the shared UI Core (env
Wio_Tracker_L2_companion_solo_lvgl, LVGL 9.2.2, pool in PSRAM):
status bar, home (clock, unread), conversation list (channels + recent
DMs with unread badges), contact picker, conversation view with bubbles,
delivery / relay markers and on-screen keyboard, toasts for Core events,
display auto-off with wake on touch / button. Board glue (LovyanGFX
flush + GT911 touch) isolated in LvglPort.h; design tokens in Theme.h.

UiCore gains the first frontend actions, sendDirectText() and
sendChannelText(), and drives DM auto-resend + unread reconcile from its
own loop (removed from ui-new's loop). MessageHistory exposes newest-first
DM ring iteration and a public contactByPrefix().

Verified on L2 hardware: navigation, send/receive, sleep/wake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-24 23:49:42 +02:00
co-authored by Claude Opus 5.5
parent 74db0d69a5
commit cd926b7e45
12 changed files with 1094 additions and 10 deletions
@@ -424,9 +424,12 @@ public:
DmHistEntry& dmAtPos(int pos) { return _dm_hist[pos]; }
const DmHistEntry& dmAtPos(int pos) const { return _dm_hist[pos]; }
private:
// Whole DM ring, newest first (j = 0 newest) -- for "recent conversations".
int dmHistCount() const { return _dm_hist_count; }
int dmHistPosNewest(int j) const { return (_dm_hist_head + _dm_hist_count - 1 - j) % DM_HIST_MAX; }
// Look up a contact by 4-byte pub_key prefix (as stored in DmHistEntry).
bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) const {
static bool contactByPrefix(const uint8_t* prefix, ContactInfo& out) {
int total = the_mesh.getNumContacts();
// +MAX_ANON_CONTACTS: see MessagesScreen.h's buildContactList() for why
// getContactByIdx() needs this offset (raw table index, not the
@@ -439,6 +442,7 @@ private:
return false;
}
private:
ChHistEntry _hist[CH_HIST_MAX];
int _hist_head, _hist_count;
uint8_t _ch_unread[MAX_GROUP_CHANNELS];
+40
View File
@@ -27,6 +27,7 @@ class UiCore : public MyMesh::Listener {
public:
void begin(NodePrefs* prefs, SensorManager* sensors, UiCoreHost* host) {
_host = host;
_prefs = prefs;
clock.begin(prefs, &events);
ping.begin(prefs);
course.begin(sensors);
@@ -37,6 +38,10 @@ public:
// Driven from the frontend's loop(), before it drains `events`.
void loop() {
// Background delivery: resend pending on-device DMs whose ACK timed out,
// and finalise the ✗ marker; free DM unread slots the ring no longer backs.
history.tickDmResends();
dm_unread.reconcile(history);
clock.loop();
course.loop();
live_share.loop();
@@ -72,6 +77,40 @@ public:
void clearAllDMUnread() { dm_unread.clearAll(); }
void reconcileDMUnread() { dm_unread.reconcile(history); }
// ── Actions ───────────────────────────────────────────────────────────────
// Send a DM composed on the device and file it (with end-to-end ACK tracking
// and auto-resend) into the history. False if it couldn't be sent.
bool sendDirectText(const ContactInfo& to, const char* text) {
uint32_t send_ts = rtc_clock.getCurrentTime();
uint32_t expected_ack = 0, est_timeout = 0;
if (the_mesh.sendMessage(to, send_ts, 0, text, expected_ack, est_timeout) <= 0) return false;
uint32_t tag = 0, deadline = 0;
if (expected_ack) {
tag = expected_ack;
// Generous margin over the base estimate so a slow multi-hop ACK isn't
// prematurely shown as failed.
deadline = millis() + est_timeout + 4000;
}
history.storeDMMsg(to.id.pub_key, true, text, tag, deadline, tag ? send_ts : 0,
_prefs ? _prefs->dm_resend_count : 0);
return true;
}
// Send a channel post composed on the device, file it as "Me: …" with the
// relay marker armed, and treat the channel as read. False if not sent.
bool sendChannelText(uint8_t channel_idx, const char* text) {
ChannelDetails ch;
if (!the_mesh.getChannel(channel_idx, ch)) return false;
if (!the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel,
the_mesh.getNodeName(), text, strlen(text))) return false;
char entry[MSG_TEXT_BUF];
snprintf(entry, sizeof(entry), "Me: %s", text);
int pos = addChannelMsg(channel_idx, entry, 0, nullptr, 0, true);
if (pos >= 0) history.armChannelRelay(pos, the_mesh.lastChannelRelaySeq());
history.setChUnread(channel_idx, 0);
return true;
}
// ════ MyMesh::Listener ════════════════════════════════════════════════════
void onQueueSizeChanged(int msgcount) override {
@@ -188,6 +227,7 @@ private:
}
UiCoreHost* _host = nullptr;
NodePrefs* _prefs = nullptr;
int _queue_len = 0; // last onQueueSizeChanged()
int _room_unread = 0;
};
@@ -0,0 +1,76 @@
#pragma once
// LVGL <-> board glue: display flush and pointer input. One implementation per
// display stack; the rest of ui-lvgl never touches the hardware directly.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp only.
namespace lvport {
#if defined(SEEED_WIO_TRACKER_L2)
// LovyanGFX device (NV3031B QSPI panel + GT911 touch) from WioTrackerL2Display.
static lgfx::LGFX_Device* s_gfx = nullptr;
static bool s_swallow = false; // ignore the touch that woke the display until it lifts
static void flushCb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) {
int w = area->x2 - area->x1 + 1;
int h = area->y2 - area->y1 + 1;
s_gfx->startWrite();
s_gfx->setAddrWindow(area->x1, area->y1, w, h);
s_gfx->pushPixels((uint16_t*)px_map, (uint32_t)w * h, true /* LVGL RGB565 is little-endian */);
s_gfx->endWrite();
lv_display_flush_ready(disp);
}
static void touchCb(lv_indev_t* indev, lv_indev_data_t* data) {
(void)indev;
lgfx::touch_point_t tp;
bool down = s_gfx->getTouch(&tp, 1) > 0;
if (s_swallow) {
if (!down) s_swallow = false;
data->state = LV_INDEV_STATE_RELEASED;
return;
}
if (down) {
data->state = LV_INDEV_STATE_PRESSED;
data->point.x = tp.x;
data->point.y = tp.y;
} else {
data->state = LV_INDEV_STATE_RELEASED;
}
}
static bool begin() {
s_gfx = display.lgfxDevice();
s_gfx->setColorDepth(16);
lv_display_t* disp = lv_display_create(s_gfx->width(), s_gfx->height());
lv_display_set_flush_cb(disp, flushCb);
// Two 40-line partial buffers (25 KB each), PSRAM first.
const uint32_t buf_sz = (uint32_t)s_gfx->width() * 40 * 2;
uint8_t* buf1 = (uint8_t*)heap_caps_malloc(buf_sz, MALLOC_CAP_SPIRAM);
uint8_t* buf2 = (uint8_t*)heap_caps_malloc(buf_sz, MALLOC_CAP_SPIRAM);
if (!buf1) { buf1 = (uint8_t*)heap_caps_malloc(buf_sz, MALLOC_CAP_8BIT); buf2 = nullptr; }
if (!buf1) return false;
lv_display_set_buffers(disp, buf1, buf2, buf_sz, LV_DISPLAY_RENDER_MODE_PARTIAL);
lv_indev_t* indev = lv_indev_create();
lv_indev_set_type(indev, LV_INDEV_TYPE_POINTER);
lv_indev_set_read_cb(indev, touchCb);
return true;
}
// Raw panel touch, read while LVGL is paused (display asleep).
static bool touched() {
lgfx::touch_point_t tp;
return s_gfx->getTouch(&tp, 1) > 0;
}
static void swallowTouch() { s_swallow = true; }
#else
#error "ui-lvgl: no LVGL port for this board (see LvglPort.h)"
#endif
} // namespace lvport
+30
View File
@@ -0,0 +1,30 @@
#pragma once
// Design tokens for ui-lvgl — the one place colours, spacing and type live.
// First colour take on the "Amber Trace" direction: amber on near-black.
namespace theme {
// Colours (0xRRGGBB)
static const uint32_t BG = 0x0B0B0D; // screen background
static const uint32_t SURFACE = 0x17171B; // cards, rows, bubbles (incoming)
static const uint32_t SURFACE_2 = 0x222228; // pressed / raised
static const uint32_t ACCENT = 0xFFB000; // amber: focus, badges, own bubbles
static const uint32_t ACCENT_DIM= 0x6B4A00; // own bubble fill
static const uint32_t TEXT = 0xF2EEE6;
static const uint32_t TEXT_MUTED= 0x9A958C;
static const uint32_t OK = 0x6FCF6F; // delivered / relayed
static const uint32_t FAIL = 0xE5534B; // not delivered
// Spacing (px)
static const int PAD = 8;
static const int GAP = 6;
static const int RADIUS = 10;
static const int STATUS_H = 22; // top status bar
static const int ROW_H = 44; // list row (touch target)
// Type
#define THEME_FONT_SMALL (&lv_font_montserrat_12)
#define THEME_FONT_BODY (&lv_font_montserrat_14)
#define THEME_FONT_TITLE (&lv_font_montserrat_16)
#define THEME_FONT_LARGE (&lv_font_montserrat_20)
#define THEME_FONT_CLOCK (&lv_font_montserrat_40)
}
+758
View File
@@ -0,0 +1,758 @@
#include "UITask.h"
#include <helpers/TxtDataHelpers.h>
#include "../MyMesh.h"
#include "target.h"
#if defined(ESP32)
#include <esp_heap_caps.h>
#endif
#include "../ui-core/UiCore.h" // shared UI Core (header-only, this TU)
#include "Theme.h"
#include "LvglPort.h"
// ui-lvgl skeleton (docs/development/ui-core.md, step 5): status bar, home,
// conversation list, contact picker, conversation view with compose. Every
// piece of state it shows comes from the UI Core; this file only draws it.
static UITask* s_ui = nullptr; // for LVGL's C callbacks
// ── Small helpers ─────────────────────────────────────────────────────────────
static void styleSurface(lv_obj_t* o, uint32_t bg) {
lv_obj_set_style_bg_color(o, lv_color_hex(bg), 0);
lv_obj_set_style_bg_opa(o, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(o, 0, 0);
lv_obj_set_style_radius(o, 0, 0);
lv_obj_set_style_pad_all(o, 0, 0);
}
static lv_obj_t* label(lv_obj_t* parent, const char* text, const lv_font_t* font, uint32_t color) {
lv_obj_t* l = lv_label_create(parent);
lv_label_set_text(l, text);
lv_obj_set_style_text_font(l, font, 0);
lv_obj_set_style_text_color(l, lv_color_hex(color), 0);
return l;
}
// Unread badge: amber pill with a count, on the right edge of `parent`.
static void badge(lv_obj_t* parent, int n, bool overflow) {
if (n <= 0) return;
lv_obj_t* b = lv_obj_create(parent);
lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(b, LV_SIZE_CONTENT, 20);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::ACCENT), 0);
lv_obj_set_style_border_width(b, 0, 0);
lv_obj_set_style_radius(b, 10, 0);
lv_obj_set_style_pad_hor(b, 7, 0);
lv_obj_set_style_pad_ver(b, 0, 0);
lv_obj_align(b, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
lv_obj_t* l = label(b, "", THEME_FONT_SMALL, theme::BG);
lv_label_set_text_fmt(l, "%d%s", n, overflow ? "+" : "");
lv_obj_center(l);
}
// "12s" / "5m" / "3h" / "2d" since a unix timestamp.
static void formatAge(char* buf, size_t n, uint32_t ts) {
uint32_t now = rtc_clock.getCurrentTime();
uint32_t d = (ts && now > ts) ? now - ts : 0;
if (d < 60) snprintf(buf, n, "%lus", (unsigned long)d);
else if (d < 3600) snprintf(buf, n, "%lum", (unsigned long)(d / 60));
else if (d < 86400) snprintf(buf, n, "%luh", (unsigned long)(d / 3600));
else snprintf(buf, n, "%lud", (unsigned long)(d / 86400));
}
static void contactName(const uint8_t* prefix, char* out, size_t n) {
ContactInfo c;
if (MessageHistory::contactByPrefix(prefix, c) && c.name[0]) snprintf(out, n, "%s", c.name);
else snprintf(out, n, "%02X%02X%02X%02X", prefix[0], prefix[1], prefix[2], prefix[3]);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────────
void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs* node_prefs) {
s_ui = this;
_display = display_drv;
_sensors = sensors;
_prefs = node_prefs;
_core = new UiCore();
_core->begin(node_prefs, sensors, this);
#ifdef PIN_USER_BTN
user_btn.begin();
#endif
lv_init();
lv_tick_set_cb([]() -> uint32_t { return (uint32_t)millis(); });
if (!lvport::begin()) {
Serial.println("ui-lvgl: no memory for display buffers");
return;
}
lv_theme_t* th = lv_theme_default_init(lv_display_get_default(), lv_color_hex(theme::ACCENT),
lv_color_hex(theme::ACCENT_DIM), true, THEME_FONT_BODY);
lv_display_set_theme(lv_display_get_default(), th);
buildStatusBar();
showHome();
}
MyMesh::Listener* UITask::meshListener() { return _core; }
void UITask::loop() {
pollConnection();
drainCoreEvents();
#ifdef PIN_USER_BTN
int ev = user_btn.check();
if (ev == BUTTON_EVENT_CLICK) {
if (_asleep) wake();
else if (_screen == SCR_HOME) sleep();
else back();
}
#endif
if (_asleep) {
if (lvport::touched()) { lvport::swallowTouch(); wake(); }
} else {
uint32_t aoff = autoOffMillis();
if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff) sleep();
}
_core->loop();
drainCoreEvents();
if (!_asleep) {
if ((int32_t)(millis() - _next_status_ms) >= 0) {
_next_status_ms = millis() + 1000;
refreshStatusBar();
if (_screen == SCR_HOME) refreshHome();
}
if (_screen == SCR_THREAD && (int32_t)(millis() - _next_thread_check_ms) >= 0) {
_next_thread_check_ms = millis() + 500;
uint32_t sig = threadSignature();
if (_thread_dirty || sig != _thread_sig) refreshThread();
}
lv_timer_handler();
}
}
void UITask::shutdown(bool restart) {
the_mesh.savePrefs();
the_mesh.saveRTCTime();
the_mesh.flushDirtyContacts();
_core->trail.onShutdown();
if (restart) {
_board->reboot();
} else {
if (_display) _display->turnOff();
radio_driver.powerOff();
if (_sensors) {
LocationProvider* loc = _sensors->getLocationProvider();
if (loc) loc->stop();
}
_board->powerOff();
}
}
uint32_t UITask::autoOffMillis() const {
if (!_prefs || _prefs->auto_off_secs == 0) return 0;
return (uint32_t)_prefs->auto_off_secs * 1000UL;
}
void UITask::sleep() {
if (_asleep) return;
_asleep = true;
if (_display) _display->turnOff();
}
void UITask::wake() {
lv_display_trigger_activity(NULL);
if (!_asleep) return;
_asleep = false;
if (_display) _display->turnOn();
refreshStatusBar();
if (_screen == SCR_HOME) refreshHome();
if (_screen == SCR_THREAD) refreshThread();
lv_obj_invalidate(lv_screen_active());
}
// ── Core events ───────────────────────────────────────────────────────────────
void UITask::drainCoreEvents() {
UiEvent ev;
while (_core->events.pop(ev)) {
switch (ev.type) {
case UiEventType::MessageArrived:
onMessageArrived(ev);
break;
case UiEventType::ClockAlert:
wake();
showToast(ev.text, ClockEngine::RING_MS);
break;
case UiEventType::ClockRingEnded:
break;
case UiEventType::LiveShareEnded:
showToast("Live share ended");
break;
case UiEventType::LocatorCrossed:
showToast(ev.text, 3000);
break;
default:
break;
}
}
}
void UITask::onMessageArrived(const UiEvent& ev) {
char buf[48];
snprintf(buf, sizeof(buf), "Msg: %.20s", ev.text);
// Wake for the message unless an app is already showing it, or the user
// turned message-wake off.
bool wake_disabled = _prefs && _prefs->msg_wake_screen_off;
if (_asleep && !wake_disabled && !isClientConnected()) wake();
else if (!_asleep) lv_display_trigger_activity(NULL);
showToast(buf, 3000);
if (_screen == SCR_CHATS) buildChats(); // new unread counts
}
bool UITask::isViewingChannel(uint8_t channel_idx) {
return _screen == SCR_THREAD && _thread_is_channel && _thread_channel == channel_idx;
}
bool UITask::isViewingDM(const uint8_t* pub_key) {
return _screen == SCR_THREAD && !_thread_is_channel && memcmp(_thread_key, pub_key, 4) == 0;
}
// ── Status bar + toast (top layer, over every screen) ─────────────────────────
void UITask::buildStatusBar() {
lv_obj_t* bar = lv_obj_create(lv_layer_top());
styleSurface(bar, theme::BG);
lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(bar, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(bar, LV_PCT(100), theme::STATUS_H);
lv_obj_align(bar, LV_ALIGN_TOP_MID, 0, 0);
lv_obj_set_style_pad_hor(bar, theme::PAD, 0);
_status_time = label(bar, "--:--", THEME_FONT_SMALL, theme::TEXT);
lv_obj_align(_status_time, LV_ALIGN_LEFT_MID, 0, 0);
_status_icons = label(bar, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_align(_status_icons, LV_ALIGN_RIGHT_MID, 0, 0);
_toast = lv_obj_create(lv_layer_top());
lv_obj_remove_flag(_toast, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(_toast, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(_toast, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_bg_color(_toast, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_set_style_border_color(_toast, lv_color_hex(theme::ACCENT), 0);
lv_obj_set_style_border_width(_toast, 1, 0);
lv_obj_set_style_radius(_toast, theme::RADIUS, 0);
lv_obj_set_style_pad_hor(_toast, 12, 0);
lv_obj_set_style_pad_ver(_toast, 6, 0);
lv_obj_align(_toast, LV_ALIGN_BOTTOM_MID, 0, -12);
label(_toast, "", THEME_FONT_BODY, theme::TEXT);
lv_obj_add_flag(_toast, LV_OBJ_FLAG_HIDDEN);
refreshStatusBar();
}
void UITask::refreshStatusBar() {
if (!_status_time) return;
uint32_t now = rtc_clock.getCurrentTime();
if (now > 1000000000UL) {
time_t t = (time_t)((int64_t)now + (int64_t)(_prefs ? _prefs->tz_offset_hours : 0) * 3600);
struct tm* ti = gmtime(&t);
lv_label_set_text_fmt(_status_time, "%02d:%02d", ti->tm_hour, ti->tm_min);
} else {
lv_label_set_text(_status_time, "--:--");
}
uint16_t mv = getBattMilliVolts();
int pct = mv <= 3300 ? 0 : mv >= 4200 ? 100 : (int)(mv - 3300) * 100 / 900;
const char* batt = pct > 80 ? LV_SYMBOL_BATTERY_FULL : pct > 55 ? LV_SYMBOL_BATTERY_3
: pct > 30 ? LV_SYMBOL_BATTERY_2 : pct > 10 ? LV_SYMBOL_BATTERY_1 : LV_SYMBOL_BATTERY_EMPTY;
bool gps = false;
if (_sensors) {
LocationProvider* loc = _sensors->getLocationProvider();
gps = loc && loc->isValid();
}
lv_label_set_text_fmt(_status_icons, "%s%s%s %d%%",
hasConnection() ? LV_SYMBOL_BLUETOOTH " " : "",
gps ? LV_SYMBOL_GPS " " : "",
batt, pct);
}
// One persistent timer, paused between toasts: hides the toast when it fires.
static void toastTimerCb(lv_timer_t* t) {
lv_obj_add_flag((lv_obj_t*)lv_timer_get_user_data(t), LV_OBJ_FLAG_HIDDEN);
lv_timer_pause(t);
}
void UITask::showToast(const char* text, uint32_t ms) {
if (!_toast) return;
lv_label_set_text(lv_obj_get_child(_toast, 0), text);
lv_obj_remove_flag(_toast, LV_OBJ_FLAG_HIDDEN);
if (!_toast_timer) _toast_timer = lv_timer_create(toastTimerCb, ms, _toast);
lv_timer_set_period(_toast_timer, ms);
lv_timer_reset(_toast_timer);
lv_timer_resume(_toast_timer);
}
// ── Screens ───────────────────────────────────────────────────────────────────
static void onBack(lv_event_t* e) { (void)e; s_ui->back(); }
// A fresh screen below the status bar, loaded in place of the current one;
// returns its content area (flex column). The old screen is deleted
// asynchronously -- this usually runs from a click on one of its own widgets.
lv_obj_t* UITask::newScreen(const char* title, bool with_back) {
_home_clock = _home_date = _home_unread = nullptr;
_thread_list = _compose_ta = _keyboard = nullptr;
lv_obj_t* prev = lv_screen_active();
lv_obj_t* scr = lv_obj_create(NULL);
styleSurface(scr, theme::BG);
lv_obj_remove_flag(scr, LV_OBJ_FLAG_SCROLLABLE);
int top = theme::STATUS_H;
if (title) {
lv_obj_t* hdr = lv_obj_create(scr);
styleSurface(hdr, theme::BG);
lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(hdr, LV_PCT(100), 32);
lv_obj_set_pos(hdr, 0, top);
if (with_back) {
lv_obj_t* b = lv_button_create(hdr);
lv_obj_set_size(b, 40, 28);
lv_obj_align(b, LV_ALIGN_LEFT_MID, 4, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_add_event_cb(b, onBack, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(b, LV_SYMBOL_LEFT, THEME_FONT_BODY, theme::TEXT));
}
lv_obj_t* t = label(hdr, title, THEME_FONT_TITLE, theme::TEXT);
lv_label_set_long_mode(t, LV_LABEL_LONG_DOT);
lv_obj_set_width(t, 230);
lv_obj_align(t, LV_ALIGN_LEFT_MID, with_back ? 52 : theme::PAD, 0);
top += 32;
}
lv_obj_t* body = lv_obj_create(scr);
styleSurface(body, theme::BG);
lv_obj_set_size(body, LV_PCT(100), lv_display_get_vertical_resolution(NULL) - top);
lv_obj_set_pos(body, 0, top);
lv_obj_set_flex_flow(body, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(body, theme::PAD, 0);
lv_obj_set_style_pad_row(body, theme::GAP, 0);
lv_screen_load(scr);
if (prev && prev != scr) lv_obj_delete_async(prev);
return body;
}
void UITask::back() {
switch (_screen) {
case SCR_THREAD: showChats(); break;
case SCR_CONTACTS: showChats(); break;
case SCR_CHATS: showHome(); break;
default: break;
}
}
// ── Home ──────────────────────────────────────────────────────────────────────
static void onOpenChats(lv_event_t* e) { (void)e; s_ui->showChats(); }
void UITask::showHome() {
_screen = SCR_HOME;
buildHome();
refreshHome();
}
void UITask::buildHome() {
lv_obj_t* body = newScreen(NULL, false);
lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
_home_clock = label(body, "--:--", THEME_FONT_CLOCK, theme::TEXT);
lv_obj_set_style_pad_top(_home_clock, 10, 0);
_home_date = label(body, "", THEME_FONT_BODY, theme::TEXT_MUTED);
lv_obj_t* name = label(body, the_mesh.getNodeName(), THEME_FONT_BODY, theme::ACCENT);
lv_obj_set_style_pad_bottom(name, 8, 0);
lv_obj_t* tile = lv_button_create(body);
lv_obj_set_size(tile, LV_PCT(100), 56);
lv_obj_set_style_bg_color(tile, lv_color_hex(theme::SURFACE), 0);
lv_obj_set_style_radius(tile, theme::RADIUS, 0);
lv_obj_set_style_shadow_width(tile, 0, 0);
lv_obj_add_event_cb(tile, onOpenChats, LV_EVENT_CLICKED, NULL);
lv_obj_t* tl = label(tile, LV_SYMBOL_ENVELOPE " Messages", THEME_FONT_TITLE, theme::TEXT);
lv_obj_align(tl, LV_ALIGN_LEFT_MID, theme::PAD, 0);
_home_unread = label(tile, "", THEME_FONT_TITLE, theme::ACCENT);
lv_obj_align(_home_unread, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
}
void UITask::refreshHome() {
if (!_home_clock) return;
uint32_t now = rtc_clock.getCurrentTime();
if (now > 1000000000UL) {
time_t t = (time_t)((int64_t)now + (int64_t)(_prefs ? _prefs->tz_offset_hours : 0) * 3600);
struct tm* ti = gmtime(&t);
static const char* DOW[] = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" };
static const char* MON[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec" };
lv_label_set_text_fmt(_home_clock, "%02d:%02d", ti->tm_hour, ti->tm_min);
lv_label_set_text_fmt(_home_date, "%s %d %s %d", DOW[ti->tm_wday], ti->tm_mday, MON[ti->tm_mon],
ti->tm_year + 1900);
} else {
lv_label_set_text(_home_clock, "--:--");
lv_label_set_text(_home_date, "time not synced");
}
int unread = _core->dmUnreadTotal() + _core->history.getTotalChannelUnread() + _core->roomUnread();
if (unread > 0) lv_label_set_text_fmt(_home_unread, "%d new", unread);
else lv_label_set_text(_home_unread, "");
}
// ── Conversation list ─────────────────────────────────────────────────────────
static void onOpenChannel(lv_event_t* e) {
s_ui->openChannel((uint8_t)(uintptr_t)lv_event_get_user_data(e));
}
// DM rows carry a 4-byte prefix; kept in a static table the rows point into.
static uint8_t s_dm_rows[MessageHistory::DM_HIST_MAX][4];
static uint8_t s_contact_rows[64][PUB_KEY_SIZE];
static void onOpenDMRow(lv_event_t* e) {
s_ui->openDM(s_dm_rows[(uintptr_t)lv_event_get_user_data(e)]);
}
static void onOpenContactRow(lv_event_t* e) {
s_ui->openDM(s_contact_rows[(uintptr_t)lv_event_get_user_data(e)]);
}
static void onNewChat(lv_event_t* e) { (void)e; s_ui->showContacts(); }
// One tappable list row: title, optional muted subtitle, optional badge.
static lv_obj_t* listRow(lv_obj_t* parent, const char* title, const char* sub,
lv_event_cb_t cb, void* user) {
lv_obj_t* row = lv_button_create(parent);
lv_obj_set_size(row, LV_PCT(100), theme::ROW_H);
lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE), 0);
lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
lv_obj_set_style_radius(row, theme::RADIUS, 0);
lv_obj_set_style_shadow_width(row, 0, 0);
lv_obj_set_style_pad_all(row, 0, 0);
lv_obj_add_event_cb(row, cb, LV_EVENT_CLICKED, user);
lv_obj_t* t = label(row, title, THEME_FONT_BODY, theme::TEXT);
lv_label_set_long_mode(t, LV_LABEL_LONG_DOT);
lv_obj_set_width(t, 230);
lv_obj_align(t, LV_ALIGN_TOP_LEFT, theme::PAD, sub ? 5 : 13);
if (sub) {
lv_obj_t* s = label(row, sub, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(s, LV_LABEL_LONG_DOT);
lv_obj_set_width(s, 230);
lv_obj_align(s, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5);
}
return row;
}
static lv_obj_t* sectionTitle(lv_obj_t* parent, const char* text) {
lv_obj_t* l = label(parent, text, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_set_style_pad_top(l, 4, 0);
return l;
}
void UITask::showChats() {
_screen = SCR_CHATS;
buildChats();
}
void UITask::buildChats() {
lv_obj_t* body = newScreen("Messages", true);
// Channels
sectionTitle(body, "CHANNELS");
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
char sub[64] = "";
int n = _core->history.histCountForChannel(i);
if (n > 0) {
const ChHistEntry& e = _core->history.chAtPos(_core->history.histEntryForChannel(i, 0));
snprintf(sub, sizeof(sub), "%s", e.text);
}
lv_obj_t* row = listRow(body, ch.name, sub[0] ? sub : NULL, onOpenChannel, (void*)(uintptr_t)i);
badge(row, _core->history.chUnread(i), _core->history.chUnreadOverflow(i));
}
// Recent direct conversations (DM ring, newest first, one row per contact)
sectionTitle(body, "DIRECT");
int rows = 0;
for (int j = 0; j < _core->history.dmHistCount() && rows < MessageHistory::DM_HIST_MAX; j++) {
const DmHistEntry& e = _core->history.dmAtPos(_core->history.dmHistPosNewest(j));
bool seen = false;
for (int r = 0; r < rows; r++) if (memcmp(s_dm_rows[r], e.prefix, 4) == 0) { seen = true; break; }
if (seen) continue;
memcpy(s_dm_rows[rows], e.prefix, 4);
char name[33];
contactName(e.prefix, name, sizeof(name));
char sub[64];
snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text);
lv_obj_t* row = listRow(body, name, sub, onOpenDMRow, (void*)(uintptr_t)rows);
badge(row, _core->dmUnread(e.prefix), _core->dmUnreadOverflow(e.prefix));
rows++;
}
int room = _core->roomUnread();
if (room > 0) {
char t[32];
snprintf(t, sizeof(t), "%d unread room post%s", room, room == 1 ? "" : "s");
sectionTitle(body, t);
}
lv_obj_t* add = lv_button_create(body);
lv_obj_set_size(add, LV_PCT(100), 36);
lv_obj_set_style_bg_color(add, lv_color_hex(theme::ACCENT_DIM), 0);
lv_obj_set_style_radius(add, theme::RADIUS, 0);
lv_obj_set_style_shadow_width(add, 0, 0);
lv_obj_add_event_cb(add, onNewChat, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(add, LV_SYMBOL_PLUS " New message", THEME_FONT_BODY, theme::TEXT));
}
// ── Contact picker (start a DM) ───────────────────────────────────────────────
void UITask::showContacts() {
_screen = SCR_CONTACTS;
buildContacts();
}
void UITask::buildContacts() {
lv_obj_t* body = newScreen("New message", true);
int total = the_mesh.getNumContacts();
int rows = 0;
// +MAX_ANON_CONTACTS: getContactByIdx() takes the raw table index (see
// MessageHistory::contactByPrefix()).
for (int i = 0; i < total && rows < (int)(sizeof(s_contact_rows) / sizeof(s_contact_rows[0])); i++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue;
if (c.type != ADV_TYPE_CHAT) continue;
memcpy(s_contact_rows[rows], c.id.pub_key, PUB_KEY_SIZE);
listRow(body, c.name, NULL, onOpenContactRow, (void*)(uintptr_t)rows);
rows++;
}
if (rows == 0) label(body, "No contacts yet", THEME_FONT_BODY, theme::TEXT_MUTED);
}
// ── Conversation ──────────────────────────────────────────────────────────────
static void onKeyboard(lv_event_t* e) {
lv_event_code_t code = lv_event_get_code(e);
if (code == LV_EVENT_READY) s_ui->sendFromCompose();
else if (code == LV_EVENT_CANCEL) lv_obj_add_flag((lv_obj_t*)lv_event_get_user_data(e), LV_OBJ_FLAG_HIDDEN);
}
static void onComposeClicked(lv_event_t* e) {
lv_obj_t* kb = (lv_obj_t*)lv_event_get_user_data(e);
lv_obj_remove_flag(kb, LV_OBJ_FLAG_HIDDEN);
}
void UITask::openChannel(uint8_t channel_idx) {
_thread_is_channel = true;
_thread_channel = channel_idx;
_core->history.setChUnread(channel_idx, 0);
_screen = SCR_THREAD;
buildThread();
}
void UITask::openDM(const uint8_t* pub_key) {
_thread_is_channel = false;
memset(_thread_key, 0, sizeof(_thread_key));
ContactInfo c;
if (MessageHistory::contactByPrefix(pub_key, c)) memcpy(_thread_key, c.id.pub_key, PUB_KEY_SIZE);
else memcpy(_thread_key, pub_key, 4);
_core->clearDMUnread(_thread_key);
_screen = SCR_THREAD;
buildThread();
}
void UITask::buildThread() {
char title[40];
bool can_send = true;
if (_thread_is_channel) {
ChannelDetails ch;
if (the_mesh.getChannel(_thread_channel, ch)) snprintf(title, sizeof(title), "# %s", ch.name);
else snprintf(title, sizeof(title), "Channel %d", _thread_channel);
} else {
contactName(_thread_key, title, sizeof(title));
ContactInfo c;
can_send = MessageHistory::contactByPrefix(_thread_key, c) && c.type == ADV_TYPE_CHAT;
}
lv_obj_t* body = newScreen(title, true);
lv_obj_set_style_pad_all(body, 0, 0);
lv_obj_set_style_pad_row(body, 0, 0);
_thread_list = lv_obj_create(body);
styleSurface(_thread_list, theme::BG);
lv_obj_set_width(_thread_list, LV_PCT(100));
lv_obj_set_flex_grow(_thread_list, 1);
lv_obj_set_flex_flow(_thread_list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_all(_thread_list, theme::PAD, 0);
lv_obj_set_style_pad_row(_thread_list, theme::GAP, 0);
_compose_ta = nullptr;
_keyboard = nullptr;
if (can_send) {
lv_obj_t* bar = lv_obj_create(body);
styleSurface(bar, theme::BG);
lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(bar, LV_PCT(100), 44);
lv_obj_set_style_pad_all(bar, 4, 0);
_compose_ta = lv_textarea_create(bar);
lv_textarea_set_one_line(_compose_ta, true);
lv_textarea_set_placeholder_text(_compose_ta, "Message");
lv_textarea_set_max_length(_compose_ta, MAX_TEXT_LEN - 40);
lv_obj_set_size(_compose_ta, LV_PCT(100), 36);
lv_obj_align(_compose_ta, LV_ALIGN_LEFT_MID, 0, 0);
// In the body's flex column below the compose bar: showing it shrinks the
// message list, so the text field stays visible just above the keys.
_keyboard = lv_keyboard_create(body);
lv_obj_set_size(_keyboard, LV_PCT(100), 130);
lv_keyboard_set_textarea(_keyboard, _compose_ta);
lv_obj_add_event_cb(_keyboard, onKeyboard, LV_EVENT_READY, _keyboard);
lv_obj_add_event_cb(_keyboard, onKeyboard, LV_EVENT_CANCEL, _keyboard);
lv_obj_add_flag(_keyboard, LV_OBJ_FLAG_HIDDEN);
lv_obj_add_event_cb(_compose_ta, onComposeClicked, LV_EVENT_CLICKED, _keyboard);
}
refreshThread();
}
// Changes whenever the open conversation's content or delivery markers change.
uint32_t UITask::threadSignature() const {
const MessageHistory& h = _core->history;
uint32_t sig = 0;
if (_thread_is_channel) {
int n = h.histCountForChannel(_thread_channel);
sig = n;
for (int j = 0; j < n && j < 8; j++) {
const ChHistEntry& e = h.chAtPos(h.histEntryForChannel(_thread_channel, j));
sig = sig * 31 + e.timestamp + e.relay_status;
}
} else {
int n = h.dmHistCountForContact(_thread_key);
sig = n;
for (int j = 0; j < n && j < 8; j++) {
const DmHistEntry& e = h.dmAtPos(h.dmHistEntryForContact(_thread_key, j));
sig = sig * 31 + e.timestamp + h.dmEffectiveStatus(e);
}
}
return sig;
}
// One message bubble. Own messages right-aligned in amber, others left.
static void bubble(lv_obj_t* list, const char* from, const char* text, bool own,
uint32_t ts, const char* status, uint32_t status_col) {
// Full-width row that pushes the bubble to its side.
lv_obj_t* row = lv_obj_create(list);
styleSurface(row, theme::BG);
lv_obj_set_style_bg_opa(row, LV_OPA_TRANSP, 0);
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, own ? LV_FLEX_ALIGN_END : LV_FLEX_ALIGN_START,
LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_t* b = lv_obj_create(row);
lv_obj_remove_flag(b, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_size(b, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_style_max_width(b, 250, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(own ? theme::ACCENT_DIM : theme::SURFACE), 0);
lv_obj_set_style_border_width(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_pad_all(b, 6, 0);
lv_obj_set_style_pad_row(b, 2, 0);
lv_obj_set_flex_flow(b, LV_FLEX_FLOW_COLUMN);
if (from && from[0]) label(b, from, THEME_FONT_SMALL, theme::ACCENT);
lv_obj_t* t = label(b, text, THEME_FONT_BODY, theme::TEXT);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_style_max_width(t, 238, 0);
lv_obj_set_width(t, LV_SIZE_CONTENT);
char meta[32];
formatAge(meta, sizeof(meta), ts);
lv_obj_t* m = label(b, meta, THEME_FONT_SMALL, theme::TEXT_MUTED);
if (status) {
lv_label_set_text_fmt(m, "%s %s", meta, status);
lv_obj_set_style_text_color(m, lv_color_hex(status_col), 0);
}
}
void UITask::refreshThread() {
if (!_thread_list) return;
_thread_dirty = false;
_thread_sig = threadSignature();
lv_obj_clean(_thread_list);
const MessageHistory& h = _core->history;
const int MAX_SHOWN = 30;
if (_thread_is_channel) {
int n = h.histCountForChannel(_thread_channel);
if (n > MAX_SHOWN) n = MAX_SHOWN;
for (int j = n - 1; j >= 0; j--) { // oldest first
const ChHistEntry& e = h.chAtPos(h.histEntryForChannel(_thread_channel, j));
// Channel text is "Sender: body"; our own posts are filed as "Me: body".
char from[40] = "";
const char* body = e.text;
const char* sep = strstr(e.text, ": ");
if (sep && sep - e.text < (int)sizeof(from)) {
memcpy(from, e.text, sep - e.text);
from[sep - e.text] = '\0';
body = sep + 2;
}
bool own = strcmp(from, "Me") == 0;
const char* st = NULL; uint32_t col = theme::TEXT_MUTED;
if (own && e.relay_status == ACK_OK) { st = LV_SYMBOL_OK " relayed"; col = theme::OK; }
else if (own && e.relay_status == ACK_PENDING) st = "sent";
bubble(_thread_list, own ? NULL : from, body, own, e.timestamp, st, col);
}
} else {
int n = h.dmHistCountForContact(_thread_key);
if (n > MAX_SHOWN) n = MAX_SHOWN;
for (int j = n - 1; j >= 0; j--) {
const DmHistEntry& e = h.dmAtPos(h.dmHistEntryForContact(_thread_key, j));
const char* st = NULL; uint32_t col = theme::TEXT_MUTED;
if (e.outgoing) {
switch (h.dmEffectiveStatus(e)) {
case ACK_OK: st = LV_SYMBOL_OK " delivered"; col = theme::OK; break;
case ACK_FAIL: st = LV_SYMBOL_CLOSE " not delivered"; col = theme::FAIL; break;
case ACK_PENDING: st = "sending..."; break;
default: st = "sent"; break;
}
}
bubble(_thread_list, NULL, e.text, e.outgoing, e.timestamp, st, col);
}
}
if (lv_obj_get_child_count(_thread_list) == 0)
label(_thread_list, "No messages yet", THEME_FONT_BODY, theme::TEXT_MUTED);
lv_obj_update_layout(_thread_list);
lv_obj_scroll_to_y(_thread_list, LV_COORD_MAX, LV_ANIM_OFF);
}
void UITask::sendFromCompose() {
if (!_compose_ta) return;
const char* text = lv_textarea_get_text(_compose_ta);
if (!text || !text[0]) return;
bool ok;
if (_thread_is_channel) {
ok = _core->sendChannelText(_thread_channel, text);
} else {
ContactInfo c;
ok = MessageHistory::contactByPrefix(_thread_key, c) && _core->sendDirectText(c, text);
}
if (ok) {
lv_textarea_set_text(_compose_ta, "");
if (_keyboard) lv_obj_add_flag(_keyboard, LV_OBJ_FLAG_HIDDEN);
refreshThread();
} else {
showToast("Send failed");
}
}
+96
View File
@@ -0,0 +1,96 @@
#pragma once
// ui-lvgl: the rich (colour + touch) frontend of the UI Core. Wio Tracker L2
// first. All application state lives in the Core (../ui-core); this class owns
// only LVGL screens, display power and input. See docs/development/ui-core.md.
#include <MeshCore.h>
#include <Arduino.h>
#include <helpers/ui/DisplayDriver.h>
#include <helpers/SensorManager.h>
#include <helpers/BaseSerialInterface.h>
#include <lvgl.h>
#include "../AbstractUITask.h"
#include "../NodePrefs.h"
#include "../ui-core/UiCoreHost.h"
class UiCore;
struct UiEvent;
class UITask : public UITaskBase, public UiCoreHost {
public:
UITask(mesh::MainBoard* board, BaseSerialInterface* serial) : UITaskBase(board, serial) {}
void begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* node_prefs);
void loop() override;
void notify(UIEventType t = UIEventType::none) override { (void)t; } // no speaker driver yet
void shutdown(bool restart = false) override;
MyMesh::Listener* meshListener() override;
// UiCoreHost
bool isViewingChannel(uint8_t channel_idx) override;
bool isViewingDM(const uint8_t* pub_key) override;
void onViewedHistoryGrew(bool channel) override { (void)channel; _thread_dirty = true; }
// ── Navigation (called from LVGL event callbacks) ────────────────────────
void showHome();
void showChats();
void showContacts();
void openChannel(uint8_t channel_idx);
void openDM(const uint8_t* pub_key);
void back();
void sendFromCompose();
void showToast(const char* text, uint32_t ms = 2500);
// Kept for main.cpp's sim hooks (ui-new API).
void openContactDM(const ContactInfo& ci) { openDM(ci.id.pub_key); }
void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; }
private:
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD };
void buildStatusBar();
void refreshStatusBar();
lv_obj_t* newScreen(const char* title, bool with_back);
void buildHome();
void refreshHome();
void buildChats();
void buildContacts();
void buildThread();
void refreshThread();
uint32_t threadSignature() const;
void drainCoreEvents();
void onMessageArrived(const UiEvent& ev);
void wake();
void sleep();
uint32_t autoOffMillis() const;
DisplayDriver* _display = nullptr;
SensorManager* _sensors = nullptr;
NodePrefs* _prefs = nullptr;
UiCore* _core = nullptr;
Screen _screen = SCR_HOME;
bool _asleep = false;
uint32_t _next_status_ms = 0;
uint32_t _next_thread_check_ms = 0;
// Open conversation (SCR_THREAD)
bool _thread_is_channel = false;
uint8_t _thread_channel = 0;
uint8_t _thread_key[PUB_KEY_SIZE] = {0};
bool _thread_dirty = false;
uint32_t _thread_sig = 0;
// Widgets
lv_obj_t* _status_time = nullptr;
lv_obj_t* _status_icons = nullptr;
lv_obj_t* _toast = nullptr;
lv_timer_t* _toast_timer = nullptr;
lv_obj_t* _home_clock = nullptr;
lv_obj_t* _home_date = nullptr;
lv_obj_t* _home_unread = nullptr;
lv_obj_t* _thread_list = nullptr;
lv_obj_t* _compose_ta = nullptr;
lv_obj_t* _keyboard = nullptr;
};
@@ -0,0 +1,51 @@
// LVGL 9 configuration for the ui-lvgl frontend. Found through
// -D LV_CONF_INCLUDE_SIMPLE + -I examples/companion_radio/ui-lvgl; anything not
// set here takes LVGL's default from lv_conf_internal.h.
#ifndef LV_CONF_H
#define LV_CONF_H
#define LV_COLOR_DEPTH 16
// Memory: LVGL's own allocator over one pool. On ESP32-S3 the pool lives in
// PSRAM so widgets / text never compete with BLE and the radio for internal RAM.
#define LV_USE_STDLIB_MALLOC LV_STDLIB_BUILTIN
#define LV_USE_STDLIB_STRING LV_STDLIB_CLIB
#define LV_USE_STDLIB_SPRINTF LV_STDLIB_CLIB
#define LV_MEM_SIZE (1024U * 1024U)
#if defined(ESP32)
#define LV_MEM_ADR 0
#define LV_MEM_POOL_INCLUDE "lv_psram_pool.h"
#define LV_MEM_POOL_ALLOC lv_psram_pool_alloc
#endif
#define LV_USE_OS LV_OS_NONE
#define LV_DEF_REFR_PERIOD 20 // ms; the loop also services the radio
#define LV_DPI_DEF 130 // 2.8" 320x240
#define LV_USE_LOG 0
#define LV_USE_ASSERT_NULL 1
#define LV_USE_ASSERT_MALLOC 1
// Fonts. Montserrat is ASCII + a few symbols only -- Polish / Cyrillic
// coverage needs a generated font (lv_font_conv), planned with the theme work.
#define LV_FONT_MONTSERRAT_12 1
#define LV_FONT_MONTSERRAT_14 1
#define LV_FONT_MONTSERRAT_16 1
#define LV_FONT_MONTSERRAT_20 1
#define LV_FONT_MONTSERRAT_40 1
#define LV_FONT_DEFAULT &lv_font_montserrat_14
#define LV_USE_THEME_DEFAULT 1
#define LV_THEME_DEFAULT_DARK 1
// Not needed by the frontend (smaller build).
#define LV_USE_CHART 0
#define LV_USE_CALENDAR 0
#define LV_USE_SPAN 0
#define LV_USE_TABLE 0
#define LV_USE_SCALE 0
#define LV_USE_LOTTIE 0
#define LV_BUILD_EXAMPLES 0
#define LV_BUILD_DEMOS 0
#endif // LV_CONF_H
@@ -0,0 +1,7 @@
#pragma once
// LVGL's builtin allocator pool, placed in PSRAM (see lv_conf.h).
#include <esp_heap_caps.h>
static inline void* lv_psram_pool_alloc(size_t sz) {
return heap_caps_malloc(sz, MALLOC_CAP_SPIRAM);
}
@@ -1088,9 +1088,6 @@ public:
_phase = DM_HIST;
}
// Background tick (called every UI loop, regardless of the active screen) that
// drives auto-resend of on-device DMs — forwarded to the history store.
void tickDmResends() { _history.tickDmResends(); }
int getDMUnreadTotal() const {
return _task->getDMUnreadTotal();
@@ -2485,10 +2485,6 @@ void UITask::pollHallSensor() {
void UITask::loop() {
pollConnection(); // BLE link state -> hasConnection() (see UITaskBase)
drainCoreEvents(); // react to what the Core filed during mesh processing (alerts, wake, sounds)
// Background delivery: resend pending on-device DMs whose ACK timed out, and
// finalise the ✗ marker — runs regardless of which screen is active.
((MessagesScreen*)messages_screen)->tickDmResends();
_core->reconcileDMUnread();
#if UI_HAS_JOYSTICK
uint8_t joy_rot = _node_prefs ? _node_prefs->joystick_rotation : JOYSTICK_ROTATION;
int ev = user_btn.check();