From cd926b7e45dc2541db2260cc581ad4432a767050 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Thu, 24 Sep 2026 23:49:42 +0200 Subject: [PATCH] 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 --- docs/development/ui-core.md | 2 +- .../companion_radio/ui-core/MessageHistory.h | 8 +- examples/companion_radio/ui-core/UiCore.h | 40 + examples/companion_radio/ui-lvgl/LvglPort.h | 76 ++ examples/companion_radio/ui-lvgl/Theme.h | 30 + examples/companion_radio/ui-lvgl/UITask.cpp | 758 ++++++++++++++++++ examples/companion_radio/ui-lvgl/UITask.h | 96 +++ examples/companion_radio/ui-lvgl/lv_conf.h | 51 ++ .../companion_radio/ui-lvgl/lv_psram_pool.h | 7 + .../companion_radio/ui-new/MessagesScreen.h | 3 - examples/companion_radio/ui-new/UITask.cpp | 4 - solo/wio-tracker-l2/platformio.ini | 29 + 12 files changed, 1094 insertions(+), 10 deletions(-) create mode 100644 examples/companion_radio/ui-lvgl/LvglPort.h create mode 100644 examples/companion_radio/ui-lvgl/Theme.h create mode 100644 examples/companion_radio/ui-lvgl/UITask.cpp create mode 100644 examples/companion_radio/ui-lvgl/UITask.h create mode 100644 examples/companion_radio/ui-lvgl/lv_conf.h create mode 100644 examples/companion_radio/ui-lvgl/lv_psram_pool.h diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index 27a24e71..9ec2687d 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -159,7 +159,7 @@ checked in the sim plus on L1 hardware before the next one. 2. **Engines, one per commit.** Clock tools ✅ (also introduced `ui-core/UiEvents.h`, the Core → frontend event queue; `UITask::tickCore()` runs `UiCore::loop()` and drains it) → ping ✅ → course-over-ground ✅ → live share ✅ → locator ✅ → trail ✅ → notifications ✅ (with step 3). `UITask` shrinks to screen management + drawing. 3. **Flip the interface** ✅. `UiCore` is `MyMesh::Listener`; `ui-new`'s `UITask` derives `UITaskBase` + `UiCoreHost` and is fed by events, drained at the start (mesh-originated) and end (engines) of its `loop()`. Mesh callbacks no longer touch the display or buzzer directly. 4. **Settings schema.** Convert `SettingsScreen` section by section. -5. **`ui-lvgl` skeleton** for L2 + sim target: boot, home, message list/conversation, keyboard. Then screens by priority. +5. **`ui-lvgl` skeleton** ✅ on L2 hardware (env `Wio_Tracker_L2_companion_solo_lvgl`, LVGL 9.2.2): status bar, home, conversation list, contact picker, conversation with keyboard, toasts, display sleep/wake. The Core gained the first actions (`sendDirectText`, `sendChannelText`) and runs DM resends itself. Still to do: sim target at 320×240, a font with Polish/Cyrillic coverage, then screens by priority. 6. Contacts/Nearby, Admin, Bot logic extraction as the LVGL screens for them are built. ## Decisions diff --git a/examples/companion_radio/ui-core/MessageHistory.h b/examples/companion_radio/ui-core/MessageHistory.h index 109ca120..c9067fcf 100644 --- a/examples/companion_radio/ui-core/MessageHistory.h +++ b/examples/companion_radio/ui-core/MessageHistory.h @@ -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]; diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 287b3e3a..665186ce 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -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; }; diff --git a/examples/companion_radio/ui-lvgl/LvglPort.h b/examples/companion_radio/ui-lvgl/LvglPort.h new file mode 100644 index 00000000..cc399218 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/LvglPort.h @@ -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 diff --git a/examples/companion_radio/ui-lvgl/Theme.h b/examples/companion_radio/ui-lvgl/Theme.h new file mode 100644 index 00000000..47c0b693 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/Theme.h @@ -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) +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp new file mode 100644 index 00000000..7b0aea88 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -0,0 +1,758 @@ +#include "UITask.h" +#include +#include "../MyMesh.h" +#include "target.h" +#if defined(ESP32) + #include +#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"); + } +} diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h new file mode 100644 index 00000000..ebc3e361 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -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 +#include +#include +#include +#include +#include + +#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; +}; diff --git a/examples/companion_radio/ui-lvgl/lv_conf.h b/examples/companion_radio/ui-lvgl/lv_conf.h new file mode 100644 index 00000000..5a05e29b --- /dev/null +++ b/examples/companion_radio/ui-lvgl/lv_conf.h @@ -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 diff --git a/examples/companion_radio/ui-lvgl/lv_psram_pool.h b/examples/companion_radio/ui-lvgl/lv_psram_pool.h new file mode 100644 index 00000000..b55605b6 --- /dev/null +++ b/examples/companion_radio/ui-lvgl/lv_psram_pool.h @@ -0,0 +1,7 @@ +#pragma once +// LVGL's builtin allocator pool, placed in PSRAM (see lv_conf.h). +#include + +static inline void* lv_psram_pool_alloc(size_t sz) { + return heap_caps_malloc(sz, MALLOC_CAP_SPIRAM); +} diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 908a5e54..f6c92c8b 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -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(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 13fc6bd0..8e9b3e27 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -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(); diff --git a/solo/wio-tracker-l2/platformio.ini b/solo/wio-tracker-l2/platformio.ini index b0070340..5dcb1fce 100644 --- a/solo/wio-tracker-l2/platformio.ini +++ b/solo/wio-tracker-l2/platformio.ini @@ -35,3 +35,32 @@ build_src_filter = ${Wio_Tracker_L2.build_src_filter} lib_deps = ${Wio_Tracker_L2.lib_deps} densaugeo/base64 @ ~1.4.0 + +; ui-lvgl skeleton: the real L2 interface -- LVGL 9 frontend over the shared UI +; Core (docs/development/ui-core.md, step 5). Not *_solo_dual yet (no release). +[env:Wio_Tracker_L2_companion_solo_lvgl] +extends = Wio_Tracker_L2 +build_flags = + ${Wio_Tracker_L2.build_flags} + -I examples/companion_radio/ui-lvgl + -D DISPLAY_CLASS=WioTrackerL2Display + -D UI_ZOOM=1 + -D LV_CONF_INCLUDE_SIMPLE + -D MAX_CONTACTS=350 + -D MAX_GROUP_CHANNELS=40 + -D BLE_PIN_CODE=123456 + -D DUAL_SERIAL=1 + -D OFFLINE_QUEUE_SIZE=256 + -D FIRMWARE_SOLO_BUILD=1 + -D MESHCORE_VERSION='"1.17.1"' + -D AUTO_OFF_MILLIS=60000 +build_src_filter = ${Wio_Tracker_L2.build_src_filter} + + + + + + + +<../examples/companion_radio/*.cpp> + +<../examples/companion_radio/ui-lvgl/*.cpp> +lib_deps = + ${Wio_Tracker_L2.lib_deps} + densaugeo/base64 @ ~1.4.0 + lvgl/lvgl @ 9.2.2 ; pinned: bump deliberately, re-test on hardware