2026-09-24 23:49:42 +02:00
|
|
|
#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)
|
2026-09-25 00:53:23 +02:00
|
|
|
#include "../ui-core/NearbyModel.h"
|
2026-09-25 19:20:42 +02:00
|
|
|
#include "../ui-core/EtaTracker.h"
|
|
|
|
|
#include "../ui-core/SettingsSchema.h"
|
|
|
|
|
#include "../ui-core/GpsAverager.h"
|
|
|
|
|
#include "../ui-core/TrackBack.h"
|
|
|
|
|
#include "../ui-core/RadioControl.h"
|
2026-09-25 19:42:44 +02:00
|
|
|
#include "../ui-core/ChannelControl.h"
|
2026-09-25 20:09:02 +02:00
|
|
|
#include "../ui-core/BotConfig.h"
|
2026-09-24 23:49:42 +02:00
|
|
|
#include "Theme.h"
|
|
|
|
|
#include "LvglPort.h"
|
2026-09-25 00:08:15 +02:00
|
|
|
#include "../ui-core/KeyboardData.h"
|
|
|
|
|
#include "Keyboard.h"
|
2026-09-24 23:49:42 +02:00
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
2026-09-25 00:36:26 +02:00
|
|
|
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
|
|
|
|
|
#include <emscripten.h>
|
|
|
|
|
// The board's one button, pressed from the simulator page (web/lvgl.html).
|
|
|
|
|
static bool s_sim_btn_click = false;
|
|
|
|
|
extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button() { s_sim_btn_click = true; }
|
|
|
|
|
#endif
|
|
|
|
|
|
2026-09-24 23:49:42 +02:00
|
|
|
// ── 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));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 20:51:00 +02:00
|
|
|
// Local time (NodePrefs::tz_offset_hours); false before the clock is set.
|
|
|
|
|
static bool localTime(const NodePrefs* p, struct tm& out) {
|
|
|
|
|
uint32_t now = rtc_clock.getCurrentTime();
|
|
|
|
|
if (now <= 1000000000UL) return false;
|
|
|
|
|
time_t t = (time_t)((int64_t)now + (int64_t)(p ? p->tz_offset_hours : 0) * 3600);
|
|
|
|
|
out = *gmtime(&t);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
// "14:05", or "2:05" (+ " PM" with suffix) with Settings > 12-hour clock.
|
|
|
|
|
static void fmtClock(char* b, size_t n, const struct tm& ti, const NodePrefs* p, bool suffix) {
|
|
|
|
|
if (p && p->clock_12h) {
|
|
|
|
|
int h = ti.tm_hour % 12;
|
|
|
|
|
snprintf(b, n, "%d:%02d%s", h ? h : 12, ti.tm_min, suffix ? (ti.tm_hour < 12 ? " AM" : " PM") : "");
|
|
|
|
|
} else {
|
|
|
|
|
snprintf(b, n, "%02d:%02d", ti.tm_hour, ti.tm_min);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// "Thu 25 Sep 2026" ("PM Thu 25 Sep 2026" on a 12-hour clock, whose big
|
|
|
|
|
// digits have no room for it).
|
|
|
|
|
static void fmtDate(char* b, size_t n, const struct tm& ti, const NodePrefs* p) {
|
|
|
|
|
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" };
|
|
|
|
|
snprintf(b, n, "%s%s %d %s %d", (p && p->clock_12h) ? (ti.tm_hour < 12 ? "AM " : "PM ") : "",
|
|
|
|
|
DOW[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon], ti.tm_year + 1900);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 23:49:42 +02:00
|
|
|
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);
|
2026-09-25 00:53:23 +02:00
|
|
|
_nearby = new NearbyModel();
|
|
|
|
|
_nearby->bindModel(_core, node_prefs);
|
|
|
|
|
_scan = new NearbyModel();
|
|
|
|
|
_scan->bindModel(_core, node_prefs);
|
|
|
|
|
_scan->setSource(NearbyModel::SRC_SCAN);
|
2026-09-24 23:49:42 +02:00
|
|
|
|
|
|
|
|
#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();
|
2026-09-25 19:20:42 +02:00
|
|
|
applyDisplayPrefs(); // a slider percentage overrides the level main.cpp set
|
2026-09-24 23:49:42 +02:00
|
|
|
showHome();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
MyMesh::Listener* UITask::meshListener() { return _core; }
|
|
|
|
|
|
|
|
|
|
void UITask::loop() {
|
|
|
|
|
pollConnection();
|
|
|
|
|
drainCoreEvents();
|
|
|
|
|
|
2026-09-25 00:36:26 +02:00
|
|
|
bool btn_click = false;
|
2026-09-24 23:49:42 +02:00
|
|
|
#ifdef PIN_USER_BTN
|
2026-09-25 00:36:26 +02:00
|
|
|
btn_click = user_btn.check() == BUTTON_EVENT_CLICK;
|
|
|
|
|
#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
|
|
|
|
|
btn_click = s_sim_btn_click;
|
|
|
|
|
s_sim_btn_click = false;
|
|
|
|
|
#endif
|
|
|
|
|
if (btn_click) {
|
2026-09-25 19:20:42 +02:00
|
|
|
if (_core->clock.isRinging()) dismissRing();
|
|
|
|
|
else if (_asleep) wake();
|
2026-09-25 20:51:00 +02:00
|
|
|
else if (_screen == SCR_HOME || locked()) sleep();
|
2026-09-24 23:49:42 +02:00
|
|
|
else back();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (_asleep) {
|
|
|
|
|
if (lvport::touched()) { lvport::swallowTouch(); wake(); }
|
|
|
|
|
} else {
|
|
|
|
|
uint32_t aoff = autoOffMillis();
|
2026-09-25 19:20:42 +02:00
|
|
|
if (aoff > 0 && lv_display_get_inactive_time(NULL) > aoff && !_core->clock.isRinging()) sleep();
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_core->loop();
|
|
|
|
|
drainCoreEvents();
|
2026-09-25 19:20:42 +02:00
|
|
|
checkLowBattery();
|
2026-09-25 11:26:44 +02:00
|
|
|
mapDownloadTick(); // a map download keeps running on any screen, and asleep
|
2026-09-25 19:20:42 +02:00
|
|
|
if ((int32_t)(millis() - _next_trackback_ms) >= 0) { // walking the trail back, on any screen
|
|
|
|
|
_next_trackback_ms = millis() + 1000;
|
|
|
|
|
navPollTrackBack();
|
|
|
|
|
}
|
2026-09-24 23:49:42 +02:00
|
|
|
|
|
|
|
|
if (!_asleep) {
|
|
|
|
|
if ((int32_t)(millis() - _next_status_ms) >= 0) {
|
|
|
|
|
_next_status_ms = millis() + 1000;
|
|
|
|
|
refreshStatusBar();
|
2026-09-25 20:51:00 +02:00
|
|
|
refreshLock();
|
2026-09-24 23:49:42 +02:00
|
|
|
if (_screen == SCR_HOME) refreshHome();
|
|
|
|
|
}
|
2026-09-25 20:51:00 +02:00
|
|
|
if (locked()) lockPoll();
|
|
|
|
|
else if (_screen == SCR_HOME) homeSwipePoll();
|
2026-09-25 00:53:23 +02:00
|
|
|
if ((_screen == SCR_NEARBY || _screen == SCR_NODE) && (int32_t)(millis() - _next_nearby_ms) >= 0) {
|
|
|
|
|
// Scan results trickle in over a few seconds: poll fast while scanning.
|
|
|
|
|
_next_nearby_ms = millis() + (_scanning ? 250 : 2000);
|
|
|
|
|
if (_scanning && (int32_t)(millis() - _scan_until_ms) >= 0) _scanning = false;
|
|
|
|
|
if (_screen == SCR_NEARBY) {
|
|
|
|
|
refreshNearbyList();
|
|
|
|
|
if (_scan_overlay) refreshScanPopup();
|
|
|
|
|
} else {
|
|
|
|
|
refreshNode();
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-25 10:57:46 +02:00
|
|
|
if (_screen == SCR_MAP) mapLoop();
|
2026-09-25 11:26:44 +02:00
|
|
|
if (_screen == SCR_WIFI) pollWifiScan();
|
2026-09-25 19:59:00 +02:00
|
|
|
if (_screen == SCR_ADMIN) adminPoll();
|
2026-09-25 20:27:32 +02:00
|
|
|
roomPoll();
|
2026-09-25 19:20:42 +02:00
|
|
|
if (_screen == SCR_CLOCK && (int32_t)(millis() - _next_clock_ms) >= 0) {
|
|
|
|
|
_next_clock_ms = millis() + 100; // stopwatch tenths
|
|
|
|
|
refreshClock();
|
|
|
|
|
}
|
2026-09-24 23:49:42 +02:00
|
|
|
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();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 19:20:42 +02:00
|
|
|
// Settings > Display & power > Battery shutdown (NodePrefs::low_batt_mv), as
|
|
|
|
|
// ui-new does it: a smoothed reading every 8 s, never while on USB power.
|
|
|
|
|
void UITask::checkLowBattery() {
|
|
|
|
|
if ((int32_t)(millis() - _next_batt_ms) < 0) return;
|
|
|
|
|
_next_batt_ms = millis() + 8000;
|
|
|
|
|
uint16_t raw = getBattMilliVolts();
|
|
|
|
|
if (raw > 0) _batt_mv = _batt_mv == 0 ? raw : (uint16_t)((_batt_mv * 4u + raw) / 5u); // EMA, alpha 0.2
|
|
|
|
|
uint16_t low = _prefs ? _prefs->low_batt_mv : 0;
|
|
|
|
|
if (low == 0 || _batt_mv == 0 || _batt_mv >= low || _board->isExternalPowered()) return;
|
|
|
|
|
wake();
|
|
|
|
|
showToast("Low battery - shutting down", 3000);
|
|
|
|
|
lv_timer_handler();
|
|
|
|
|
delay(2000);
|
|
|
|
|
shutdown();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 23:49:42 +02:00
|
|
|
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();
|
2026-09-25 20:51:00 +02:00
|
|
|
if (_prefs && _prefs->auto_lock) lockScreen(); // Settings > Display & power > Lock screen
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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:
|
2026-09-25 19:20:42 +02:00
|
|
|
showRing(ev.text);
|
2026-09-24 23:49:42 +02:00
|
|
|
break;
|
|
|
|
|
case UiEventType::ClockRingEnded:
|
2026-09-25 19:20:42 +02:00
|
|
|
hideRing();
|
2026-09-24 23:49:42 +02:00
|
|
|
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);
|
2026-09-25 20:27:32 +02:00
|
|
|
if (_screen == SCR_CHATS && !_nav_overlay) buildChats(); // new unread counts (not under an open popup)
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-09-25 19:20:42 +02:00
|
|
|
_status_gps = label(bar, LV_SYMBOL_GPS, THEME_FONT_SMALL, theme::TEXT_MUTED);
|
2026-09-24 23:49:42 +02:00
|
|
|
|
|
|
|
|
_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);
|
2026-09-25 19:20:42 +02:00
|
|
|
lv_obj_t* tl = label(_toast, "", THEME_FONT_BODY, theme::TEXT);
|
|
|
|
|
lv_label_set_long_mode(tl, LV_LABEL_LONG_WRAP); // long texts wrap instead of running off the screen
|
|
|
|
|
lv_obj_set_style_max_width(tl, lv_display_get_horizontal_resolution(NULL) - 40, 0);
|
|
|
|
|
lv_obj_set_style_text_align(tl, LV_TEXT_ALIGN_CENTER, 0);
|
2026-09-24 23:49:42 +02:00
|
|
|
lv_obj_add_flag(_toast, LV_OBJ_FLAG_HIDDEN);
|
|
|
|
|
|
|
|
|
|
refreshStatusBar();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::refreshStatusBar() {
|
|
|
|
|
if (!_status_time) return;
|
2026-09-25 20:51:00 +02:00
|
|
|
struct tm ti;
|
|
|
|
|
if (localTime(_prefs, ti)) {
|
|
|
|
|
char clk[12];
|
|
|
|
|
fmtClock(clk, sizeof(clk), ti, _prefs, true);
|
|
|
|
|
lv_label_set_text(_status_time, clk);
|
2026-09-24 23:49:42 +02:00
|
|
|
} 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;
|
2026-09-25 19:20:42 +02:00
|
|
|
lv_label_set_text_fmt(_status_icons, "%s%s %d%%", hasConnection() ? LV_SYMBOL_BLUETOOTH " " : "", batt, pct);
|
|
|
|
|
// GPS: green with a fix, muted while on and searching, absent when off.
|
|
|
|
|
int32_t lat, lon;
|
|
|
|
|
bool fix = _core->course.currentLocation(lat, lon);
|
|
|
|
|
if (_core->gpsEnabled() || fix) {
|
|
|
|
|
lv_obj_set_style_text_color(_status_gps, lv_color_hex(fix ? theme::OK : theme::TEXT_MUTED), 0);
|
|
|
|
|
lv_obj_remove_flag(_status_gps, LV_OBJ_FLAG_HIDDEN);
|
|
|
|
|
lv_obj_align_to(_status_gps, _status_icons, LV_ALIGN_OUT_LEFT_MID, -8, 0);
|
|
|
|
|
} else {
|
|
|
|
|
lv_obj_add_flag(_status_gps, LV_OBJ_FLAG_HIDDEN);
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
2026-09-25 19:20:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::setGps(bool on) {
|
|
|
|
|
if (!_core->setGpsEnabled(on)) { showToast("No GPS on this device"); return; }
|
|
|
|
|
showToast(on ? "GPS on, waiting for a fix" : "GPS off");
|
|
|
|
|
refreshStatusBar();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::botSetGPS(bool on) { if (_core->setGpsEnabled(on)) refreshStatusBar(); }
|
|
|
|
|
|
|
|
|
|
bool UITask::ensureGps() {
|
|
|
|
|
int32_t lat, lon;
|
|
|
|
|
if (_core->course.currentLocation(lat, lon)) return true;
|
|
|
|
|
if (_core->gpsAvailable() && !_core->gpsEnabled()) setGps(true);
|
|
|
|
|
else showToast("Waiting for a GPS fix");
|
|
|
|
|
return false;
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
2026-09-25 00:36:26 +02:00
|
|
|
_header = _body = nullptr;
|
2026-09-25 00:53:23 +02:00
|
|
|
_nearby_list = _nearby_status = _nearby_sort_lbl = _nearby_chips = nullptr;
|
|
|
|
|
_node_info = _node_ping = _node_delete_lbl = nullptr;
|
|
|
|
|
_scan_overlay = _scan_list = _scan_status = nullptr; // the popup went with the old screen
|
2026-09-25 11:26:44 +02:00
|
|
|
_map_area = _map_marks = _map_me = _map_zoom_lbl = _map_hint = _map_dl_pill = nullptr;
|
2026-09-25 19:20:42 +02:00
|
|
|
_dl_overlay = _dl_info = _dl_zoom_lbl = _dl_start_lbl = _dl_job_row = _dl_job_lbl = nullptr;
|
|
|
|
|
_nav_bar = _nav_title = _nav_info = _nav_clear = nullptr;
|
|
|
|
|
_nav_overlay = _nav_ta = _nav_kb = _nav_del_lbl = _nav_rec = _nav_avg_pill = nullptr;
|
|
|
|
|
_nav_trail_lbl = _nav_trail_btn = _nav_reset_lbl = _nav_share_lbl = _nav_share_btn = _nav_tb_btn = nullptr;
|
2026-09-25 11:26:44 +02:00
|
|
|
_wifi_ssid = _wifi_pass = _wifi_kb = _wifi_list = _wifi_status = nullptr;
|
|
|
|
|
_wifi_scanning = false;
|
2026-09-25 10:57:46 +02:00
|
|
|
for (lv_obj_t*& t : _map_tiles) t = nullptr;
|
2026-09-24 23:49:42 +02:00
|
|
|
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);
|
2026-09-25 00:36:26 +02:00
|
|
|
_header = hdr;
|
2026-09-24 23:49:42 +02:00
|
|
|
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);
|
2026-09-25 00:36:26 +02:00
|
|
|
_body = body;
|
2026-09-24 23:49:42 +02:00
|
|
|
lv_screen_load(scr);
|
|
|
|
|
if (prev && prev != scr) lv_obj_delete_async(prev);
|
|
|
|
|
return body;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::back() {
|
|
|
|
|
switch (_screen) {
|
2026-09-25 19:42:44 +02:00
|
|
|
case SCR_THREAD: if (_nav_overlay) navClosePopup(); else showChats(); break;
|
2026-09-24 23:49:42 +02:00
|
|
|
case SCR_CONTACTS: showChats(); break;
|
2026-09-25 19:42:44 +02:00
|
|
|
case SCR_CHANNEL_EDIT: showChats(); break;
|
2026-09-25 20:09:02 +02:00
|
|
|
case SCR_BOT: if (_nav_overlay) navClosePopup(); else showHome(); break;
|
2026-09-25 20:51:00 +02:00
|
|
|
case SCR_FAVS: if (_nav_overlay) navClosePopup(); else showHome(); break;
|
2026-09-25 19:59:00 +02:00
|
|
|
case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break;
|
2026-09-25 20:51:00 +02:00
|
|
|
case SCR_SETTINGS: if (_nav_overlay) navClosePopup(); else showHome(); break;
|
2026-09-25 19:20:42 +02:00
|
|
|
case SCR_SETTINGS_NAV: showSettings(); break;
|
|
|
|
|
case SCR_CLOCK: showHome(); break;
|
|
|
|
|
case SCR_RADIO: if (radioPopupOpen()) radioCloseFreq(); else showSettings(); break;
|
2026-09-25 19:42:44 +02:00
|
|
|
case SCR_CHATS: if (_nav_overlay) navClosePopup(); else showHome(); break;
|
2026-09-25 00:53:23 +02:00
|
|
|
case SCR_NEARBY:
|
|
|
|
|
if (_scan_overlay) closeScanPopup();
|
|
|
|
|
else showHome();
|
|
|
|
|
break;
|
2026-09-25 10:57:46 +02:00
|
|
|
case SCR_NODE: // back to where the node was picked: map, the list, or the scan popup over it
|
2026-09-25 19:20:42 +02:00
|
|
|
if (_node_from_map) { openMap(false); break; }
|
2026-09-25 00:53:23 +02:00
|
|
|
_screen = SCR_NEARBY;
|
|
|
|
|
buildNearby();
|
|
|
|
|
if (_node_from_scan) showScanPopup();
|
|
|
|
|
break;
|
2026-09-25 11:26:44 +02:00
|
|
|
case SCR_MAP:
|
2026-09-25 19:20:42 +02:00
|
|
|
if (_nav_overlay) navClosePopup();
|
|
|
|
|
else if (_dl_overlay) mapDownloadClose();
|
|
|
|
|
else if (!_map_nav) showNearby(); // the Nodes map opens from Nearby
|
2026-09-25 11:26:44 +02:00
|
|
|
else showHome();
|
|
|
|
|
break;
|
|
|
|
|
case SCR_WIFI: if (_wifi_from_map) showMap(); else showSettings(); break;
|
2026-09-24 23:49:42 +02:00
|
|
|
default: break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Home ──────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
static void onOpenChats(lv_event_t* e) { (void)e; s_ui->showChats(); }
|
2026-09-25 00:08:15 +02:00
|
|
|
static void onOpenSettings(lv_event_t* e) { (void)e; s_ui->showSettings(); }
|
|
|
|
|
|
2026-09-25 00:53:23 +02:00
|
|
|
static void onOpenNearby(lv_event_t* e) { (void)e; s_ui->showNearby(); }
|
2026-09-25 19:20:42 +02:00
|
|
|
static void onOpenNodesMap(lv_event_t* e) { (void)e; s_ui->openMap(false); }
|
|
|
|
|
static void onOpenNav(lv_event_t* e) { (void)e; s_ui->openMap(true); }
|
|
|
|
|
static void onOpenClock(lv_event_t* e); // ClockScreen.h
|
2026-09-25 20:09:02 +02:00
|
|
|
static void onOpenBot(lv_event_t* e); // BotScreen.h
|
2026-09-25 20:51:00 +02:00
|
|
|
static void onOpenFavourites(lv_event_t* e); // DeviceScreen.h
|
|
|
|
|
static void onNodeName(lv_event_t* e);
|
|
|
|
|
static void onPowerRow(lv_event_t* e);
|
2026-09-25 00:53:23 +02:00
|
|
|
|
2026-09-25 19:20:42 +02:00
|
|
|
// Home apps, in order; PER_PAGE to a page, further pages are swiped to (with
|
|
|
|
|
// dots underneath), so new apps don't squeeze the row.
|
|
|
|
|
namespace home {
|
|
|
|
|
struct App { const char* icon; const char* text; lv_event_cb_t cb; bool unread; };
|
|
|
|
|
static const App APPS[] = {
|
|
|
|
|
{ LV_SYMBOL_ENVELOPE, "Messages", onOpenChats, true },
|
|
|
|
|
{ UI_SYMBOL_USERS, "Nearby", onOpenNearby, false },
|
|
|
|
|
{ UI_SYMBOL_MAP, "Map", onOpenNav, false },
|
|
|
|
|
{ LV_SYMBOL_SETTINGS, "Settings", onOpenSettings, false },
|
|
|
|
|
// Page 2: tools
|
2026-09-25 20:51:00 +02:00
|
|
|
{ UI_SYMBOL_STAR, "Favourites", onOpenFavourites, false },
|
2026-09-25 19:20:42 +02:00
|
|
|
{ UI_SYMBOL_CLOCK, "Clock", onOpenClock, false },
|
2026-09-25 20:09:02 +02:00
|
|
|
{ LV_SYMBOL_CHARGE, "Bot", onOpenBot, false },
|
2026-09-25 19:20:42 +02:00
|
|
|
};
|
|
|
|
|
static const int COUNT = sizeof(APPS) / sizeof(APPS[0]);
|
|
|
|
|
static const int PER_PAGE = 4;
|
|
|
|
|
static const int PAGES = (COUNT + PER_PAGE - 1) / PER_PAGE;
|
|
|
|
|
static lv_obj_t* s_row = nullptr; // the current page's tiles
|
|
|
|
|
static lv_obj_t* s_dots = nullptr;
|
|
|
|
|
static int s_page = 0; // kept across rebuilds (the home screen is rebuilt on return)
|
|
|
|
|
|
|
|
|
|
// A horizontal swipe anywhere on Home turns the page (the tiles row alone is
|
|
|
|
|
// too small a target), tracked from the touch itself: LVGL's gesture detector
|
|
|
|
|
// drops a slow swipe (its sum resets on every read without movement). Tapping
|
|
|
|
|
// a dot also goes to that page.
|
|
|
|
|
static bool s_touching = false, s_swiped = false;
|
|
|
|
|
static lv_point_t s_start;
|
|
|
|
|
static const int SWIPE_PX = 40;
|
|
|
|
|
static void onDot(lv_event_t* e) { s_ui->setHomePage((int)(uintptr_t)lv_event_get_user_data(e)); }
|
|
|
|
|
} // namespace home
|
|
|
|
|
|
|
|
|
|
// Home tile: icon over label, one of a page's row; optional amber value
|
2026-09-25 00:53:23 +02:00
|
|
|
// (unread count) in its top-right corner.
|
|
|
|
|
static lv_obj_t* homeTile(lv_obj_t* parent, const char* icon, const char* text, lv_event_cb_t cb,
|
|
|
|
|
lv_obj_t** value_out) {
|
2026-09-25 00:08:15 +02:00
|
|
|
lv_obj_t* tile = lv_button_create(parent);
|
2026-09-25 00:53:23 +02:00
|
|
|
lv_obj_set_height(tile, 64);
|
|
|
|
|
lv_obj_set_flex_grow(tile, 1);
|
2026-09-25 00:08:15 +02:00
|
|
|
lv_obj_set_style_bg_color(tile, lv_color_hex(theme::SURFACE), 0);
|
2026-09-25 00:53:23 +02:00
|
|
|
lv_obj_set_style_bg_color(tile, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
|
2026-09-25 00:08:15 +02:00
|
|
|
lv_obj_set_style_radius(tile, theme::RADIUS, 0);
|
|
|
|
|
lv_obj_set_style_shadow_width(tile, 0, 0);
|
2026-09-25 00:53:23 +02:00
|
|
|
lv_obj_set_style_pad_all(tile, 4, 0);
|
2026-09-25 00:08:15 +02:00
|
|
|
lv_obj_add_event_cb(tile, cb, LV_EVENT_CLICKED, NULL);
|
2026-09-25 00:53:23 +02:00
|
|
|
lv_obj_align(label(tile, icon, THEME_FONT_LARGE, theme::TEXT), LV_ALIGN_TOP_MID, 0, 6);
|
|
|
|
|
lv_obj_align(label(tile, text, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_BOTTOM_MID, 0, -4);
|
2026-09-25 00:08:15 +02:00
|
|
|
if (value_out) {
|
2026-09-25 00:53:23 +02:00
|
|
|
*value_out = label(tile, "", THEME_FONT_SMALL, theme::ACCENT);
|
|
|
|
|
lv_obj_align(*value_out, LV_ALIGN_TOP_RIGHT, -2, 0);
|
2026-09-25 00:08:15 +02:00
|
|
|
}
|
|
|
|
|
return tile;
|
|
|
|
|
}
|
2026-09-24 23:49:42 +02:00
|
|
|
|
|
|
|
|
void UITask::showHome() {
|
|
|
|
|
_screen = SCR_HOME;
|
2026-09-25 19:20:42 +02:00
|
|
|
_share_text[0] = '\0'; // a share not sent to anyone
|
2026-09-24 23:49:42 +02:00
|
|
|
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);
|
|
|
|
|
_home_date = label(body, "", THEME_FONT_BODY, theme::TEXT_MUTED);
|
|
|
|
|
lv_obj_t* name = label(body, the_mesh.getNodeName(), THEME_FONT_BODY, theme::ACCENT);
|
2026-09-25 00:08:15 +02:00
|
|
|
lv_obj_set_style_pad_bottom(name, 4, 0);
|
2026-09-24 23:49:42 +02:00
|
|
|
|
2026-09-25 19:20:42 +02:00
|
|
|
lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); // a drag is a page swipe, not a scroll
|
|
|
|
|
home::s_row = lv_obj_create(body);
|
|
|
|
|
styleSurface(home::s_row, theme::BG);
|
|
|
|
|
lv_obj_remove_flag(home::s_row, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
|
lv_obj_set_size(home::s_row, LV_PCT(100), LV_SIZE_CONTENT);
|
|
|
|
|
lv_obj_set_flex_flow(home::s_row, LV_FLEX_FLOW_ROW);
|
|
|
|
|
lv_obj_set_style_pad_column(home::s_row, theme::GAP, 0);
|
|
|
|
|
home::s_dots = nullptr;
|
|
|
|
|
if (home::PAGES > 1) {
|
|
|
|
|
home::s_dots = lv_obj_create(body);
|
|
|
|
|
lv_obj_remove_style_all(home::s_dots);
|
|
|
|
|
lv_obj_set_size(home::s_dots, LV_SIZE_CONTENT, 22);
|
|
|
|
|
lv_obj_set_flex_flow(home::s_dots, LV_FLEX_FLOW_ROW);
|
|
|
|
|
lv_obj_set_flex_align(home::s_dots, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
|
|
|
lv_obj_set_style_pad_column(home::s_dots, 12, 0);
|
|
|
|
|
for (int p = 0; p < home::PAGES; p++) {
|
|
|
|
|
lv_obj_t* d = lv_obj_create(home::s_dots);
|
|
|
|
|
lv_obj_remove_style_all(d);
|
|
|
|
|
lv_obj_set_size(d, 8, 8);
|
|
|
|
|
lv_obj_set_style_radius(d, LV_RADIUS_CIRCLE, 0);
|
|
|
|
|
lv_obj_set_style_bg_opa(d, LV_OPA_COVER, 0);
|
|
|
|
|
lv_obj_set_ext_click_area(d, 8);
|
|
|
|
|
lv_obj_add_flag(d, LV_OBJ_FLAG_CLICKABLE);
|
|
|
|
|
lv_obj_add_event_cb(d, home::onDot, LV_EVENT_CLICKED, (void*)(uintptr_t)p);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setHomePage(home::s_page);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// From loop() on Home: turn the page once a press has moved SWIPE_PX sideways.
|
|
|
|
|
void UITask::homeSwipePoll() {
|
|
|
|
|
lv_indev_t* in = lv_indev_get_next(NULL);
|
|
|
|
|
if (!in || home::PAGES < 2) return;
|
|
|
|
|
bool down = lv_indev_get_state(in) == LV_INDEV_STATE_PRESSED;
|
|
|
|
|
lv_point_t p;
|
|
|
|
|
lv_indev_get_point(in, &p);
|
|
|
|
|
if (!down) { home::s_touching = false; return; }
|
|
|
|
|
if (!home::s_touching) { home::s_touching = true; home::s_swiped = false; home::s_start = p; return; }
|
|
|
|
|
if (home::s_swiped) return;
|
|
|
|
|
int dx = p.x - home::s_start.x, dy = p.y - home::s_start.y;
|
|
|
|
|
if (abs(dx) < home::SWIPE_PX || abs(dx) < 2 * abs(dy)) return;
|
|
|
|
|
home::s_swiped = true;
|
|
|
|
|
lv_indev_wait_release(in); // the swipe isn't also a tap on the tile it started on
|
|
|
|
|
setHomePage(home::s_page + (dx < 0 ? 1 : -1));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::setHomePage(int page) {
|
|
|
|
|
if (_screen != SCR_HOME || !home::s_row) return; // s_row went with an older screen
|
|
|
|
|
if (page < 0 || page >= home::PAGES) return;
|
|
|
|
|
home::s_page = page;
|
|
|
|
|
lv_obj_clean(home::s_row);
|
|
|
|
|
_home_unread = nullptr;
|
|
|
|
|
for (int i = page * home::PER_PAGE; i < (page + 1) * home::PER_PAGE; i++) {
|
|
|
|
|
if (i < home::COUNT) {
|
|
|
|
|
const home::App& a = home::APPS[i];
|
|
|
|
|
homeTile(home::s_row, a.icon, a.text, a.cb, a.unread ? &_home_unread : NULL);
|
|
|
|
|
} else { // keep a short last page's tiles the same width
|
|
|
|
|
lv_obj_t* gap = lv_obj_create(home::s_row);
|
|
|
|
|
lv_obj_remove_style_all(gap);
|
|
|
|
|
lv_obj_set_height(gap, 1);
|
|
|
|
|
lv_obj_set_flex_grow(gap, 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for (int i = 0; home::s_dots && i < (int)lv_obj_get_child_count(home::s_dots); i++)
|
|
|
|
|
lv_obj_set_style_bg_color(lv_obj_get_child(home::s_dots, i), lv_color_hex(i == page ? theme::ACCENT : theme::SURFACE_2), 0);
|
|
|
|
|
refreshHome();
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::refreshHome() {
|
|
|
|
|
if (!_home_clock) return;
|
2026-09-25 20:51:00 +02:00
|
|
|
struct tm ti;
|
|
|
|
|
if (localTime(_prefs, ti)) {
|
|
|
|
|
char clk[12], date[48];
|
|
|
|
|
fmtClock(clk, sizeof(clk), ti, _prefs, false);
|
|
|
|
|
fmtDate(date, sizeof(date), ti, _prefs);
|
|
|
|
|
lv_label_set_text(_home_clock, clk);
|
|
|
|
|
lv_label_set_text(_home_date, date);
|
2026-09-24 23:49:42 +02:00
|
|
|
} else {
|
|
|
|
|
lv_label_set_text(_home_clock, "--:--");
|
|
|
|
|
lv_label_set_text(_home_date, "time not synced");
|
|
|
|
|
}
|
2026-09-25 19:20:42 +02:00
|
|
|
if (!_home_unread) return;
|
2026-09-24 23:49:42 +02:00
|
|
|
int unread = _core->dmUnreadTotal() + _core->history.getTotalChannelUnread() + _core->roomUnread();
|
2026-09-25 00:53:23 +02:00
|
|
|
if (unread > 0) lv_label_set_text_fmt(_home_unread, "%d", unread);
|
2026-09-24 23:49:42 +02:00
|
|
|
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(); }
|
2026-09-25 19:42:44 +02:00
|
|
|
static void onChanRowHold(lv_event_t* e); // ChannelScreen.h
|
|
|
|
|
static void onChanAdd(lv_event_t* e);
|
|
|
|
|
static void onChanThreadMenu(lv_event_t* e);
|
2026-09-25 20:27:32 +02:00
|
|
|
static uint8_t s_room_rows[16][PUB_KEY_SIZE];
|
|
|
|
|
static void onDMRowHold(lv_event_t* e); // ConversationScreen.h
|
|
|
|
|
static void onRoomRow(lv_event_t* e);
|
|
|
|
|
static void onRoomRowHold(lv_event_t* e);
|
|
|
|
|
static void onConvThreadMenu(lv_event_t* e);
|
|
|
|
|
static void onMsgHold(lv_event_t* e);
|
|
|
|
|
static void onChatFilter(lv_event_t* e);
|
|
|
|
|
enum : uint8_t { CF_CHANNELS, CF_ROOMS, CF_CONTACTS }; // favourites-only filters
|
2026-09-24 23:49:42 +02:00
|
|
|
|
|
|
|
|
// 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);
|
2026-09-25 19:20:42 +02:00
|
|
|
lv_obj_set_size(s, 230, 15); // fixed height: LONG_DOT cuts instead of wrapping over the title
|
2026-09-24 23:49:42 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 20:27:32 +02:00
|
|
|
// Section title with an "All" / "★ Fav" pill on the right that flips the
|
|
|
|
|
// section's favourites-only filter.
|
|
|
|
|
static void sectionWithFilter(lv_obj_t* parent, const char* text, bool fav_only, uint8_t which) {
|
|
|
|
|
lv_obj_t* row = lv_obj_create(parent);
|
|
|
|
|
styleSurface(row, theme::BG);
|
|
|
|
|
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
|
lv_obj_set_size(row, LV_PCT(100), 26);
|
|
|
|
|
lv_obj_align(label(row, text, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_BOTTOM_LEFT, 0, -2);
|
|
|
|
|
lv_obj_t* b = lv_button_create(row);
|
|
|
|
|
lv_obj_set_size(b, LV_SIZE_CONTENT, 24);
|
|
|
|
|
lv_obj_set_style_pad_hor(b, 10, 0);
|
|
|
|
|
lv_obj_set_style_pad_ver(b, 0, 0);
|
|
|
|
|
lv_obj_set_style_radius(b, 12, 0);
|
|
|
|
|
lv_obj_set_style_shadow_width(b, 0, 0);
|
|
|
|
|
lv_obj_set_style_bg_color(b, lv_color_hex(fav_only ? theme::ACCENT_DIM : theme::SURFACE), 0);
|
|
|
|
|
lv_obj_align(b, LV_ALIGN_RIGHT_MID, -2, 0);
|
|
|
|
|
lv_obj_add_event_cb(b, onChatFilter, LV_EVENT_CLICKED, (void*)(uintptr_t)which);
|
|
|
|
|
lv_obj_center(label(b, fav_only ? UI_SYMBOL_STAR " Fav" : "All", THEME_FONT_SMALL, theme::TEXT));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 23:49:42 +02:00
|
|
|
void UITask::showChats() {
|
|
|
|
|
_screen = SCR_CHATS;
|
|
|
|
|
buildChats();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::buildChats() {
|
|
|
|
|
lv_obj_t* body = newScreen("Messages", true);
|
|
|
|
|
|
2026-09-25 19:42:44 +02:00
|
|
|
// Channels: favourites first (unless turned off), hold a row for its options
|
2026-09-25 20:27:32 +02:00
|
|
|
bool ch_fav_only = _prefs && _prefs->ch_fav_only;
|
|
|
|
|
sectionWithFilter(body, "CHANNELS", ch_fav_only, CF_CHANNELS);
|
2026-09-25 19:42:44 +02:00
|
|
|
bool fav_first = !(_prefs && _prefs->fav_sort_off);
|
2026-09-25 20:27:32 +02:00
|
|
|
int ch_rows = 0;
|
2026-09-25 19:42:44 +02:00
|
|
|
for (int pass = fav_first ? 0 : 1; pass < 2; pass++) {
|
|
|
|
|
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
|
|
|
|
ChannelDetails ch;
|
|
|
|
|
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') continue;
|
|
|
|
|
bool fav = chanctl::favourite(_prefs, i);
|
2026-09-25 20:27:32 +02:00
|
|
|
if (ch_fav_only && !fav) continue;
|
2026-09-25 19:42:44 +02:00
|
|
|
if (fav_first && fav != (pass == 0)) continue;
|
2026-09-25 20:27:32 +02:00
|
|
|
ch_rows++;
|
2026-09-25 19:42:44 +02:00
|
|
|
char title[48], sub[64] = "";
|
|
|
|
|
snprintf(title, sizeof(title), "%s%s%s", fav ? UI_SYMBOL_STAR " " : "", ch.name,
|
|
|
|
|
chanctl::notif(_prefs, i) == chanctl::NOTIF_MUTED ? " " LV_SYMBOL_MUTE : "");
|
|
|
|
|
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, title, sub[0] ? sub : NULL, onOpenChannel, (void*)(uintptr_t)i);
|
|
|
|
|
lv_obj_add_event_cb(row, onChanRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)i);
|
|
|
|
|
badge(row, _core->history.chUnread(i), _core->history.chUnreadOverflow(i));
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-09-25 20:27:32 +02:00
|
|
|
if (ch_rows == 0 && ch_fav_only) label(body, "No favourite channels", THEME_FONT_SMALL, theme::TEXT_MUTED);
|
2026-09-25 19:42:44 +02:00
|
|
|
lv_obj_t* add_ch = lv_button_create(body);
|
|
|
|
|
lv_obj_set_size(add_ch, LV_PCT(100), 32);
|
|
|
|
|
lv_obj_set_style_bg_color(add_ch, lv_color_hex(theme::BG), 0);
|
|
|
|
|
lv_obj_set_style_border_color(add_ch, lv_color_hex(theme::SURFACE_2), 0);
|
|
|
|
|
lv_obj_set_style_border_width(add_ch, 1, 0);
|
|
|
|
|
lv_obj_set_style_radius(add_ch, theme::RADIUS, 0);
|
|
|
|
|
lv_obj_set_style_shadow_width(add_ch, 0, 0);
|
|
|
|
|
lv_obj_add_event_cb(add_ch, onChanAdd, LV_EVENT_CLICKED, NULL);
|
|
|
|
|
lv_obj_center(label(add_ch, LV_SYMBOL_PLUS " Add channel", THEME_FONT_SMALL, theme::TEXT_MUTED));
|
2026-09-24 23:49:42 +02:00
|
|
|
|
|
|
|
|
// 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;
|
2026-09-25 20:27:32 +02:00
|
|
|
ContactInfo c;
|
|
|
|
|
bool known = MessageHistory::contactByPrefix(e.prefix, c);
|
|
|
|
|
if (known && c.type == ADV_TYPE_ROOM) continue; // rooms have their own section
|
2026-09-24 23:49:42 +02:00
|
|
|
memcpy(s_dm_rows[rows], e.prefix, 4);
|
2026-09-25 20:27:32 +02:00
|
|
|
char name[48];
|
2026-09-24 23:49:42 +02:00
|
|
|
contactName(e.prefix, name, sizeof(name));
|
2026-09-25 20:27:32 +02:00
|
|
|
if (known && contactctl::favourite(c)) { char t[48]; snprintf(t, sizeof(t), UI_SYMBOL_STAR " %s", name); strcpy(name, t); }
|
|
|
|
|
if (known && contactctl::notif(_prefs, c.id.pub_key) == contactctl::NOTIF_MUTED) strncat(name, " " LV_SYMBOL_MUTE, sizeof(name) - strlen(name) - 1);
|
2026-09-24 23:49:42 +02:00
|
|
|
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);
|
2026-09-25 20:27:32 +02:00
|
|
|
if (known) lv_obj_add_event_cb(row, onDMRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)rows);
|
2026-09-24 23:49:42 +02:00
|
|
|
badge(row, _core->dmUnread(e.prefix), _core->dmUnreadOverflow(e.prefix));
|
|
|
|
|
rows++;
|
|
|
|
|
}
|
2026-09-25 20:27:32 +02:00
|
|
|
if (rows == 0) label(body, "No conversations yet", THEME_FONT_SMALL, theme::TEXT_MUTED);
|
|
|
|
|
|
|
|
|
|
// Room servers: tap logs in (saved password or ask) and opens; hold: options
|
|
|
|
|
bool room_fav_only = _prefs && _prefs->room_fav_only;
|
|
|
|
|
int room_unread = _core->roomUnread();
|
|
|
|
|
char rt[32];
|
|
|
|
|
if (room_unread > 0) snprintf(rt, sizeof(rt), "ROOMS - %d new", room_unread);
|
|
|
|
|
else snprintf(rt, sizeof(rt), "ROOMS");
|
|
|
|
|
sectionWithFilter(body, rt, room_fav_only, CF_ROOMS);
|
|
|
|
|
int nrooms = 0, total = the_mesh.getNumContacts();
|
|
|
|
|
const int MAX_ROOMS = (int)(sizeof(s_room_rows) / sizeof(s_room_rows[0]));
|
|
|
|
|
for (int pass = fav_first ? 0 : 1; pass < 2; pass++) {
|
|
|
|
|
for (int i = 0; i < total && nrooms < MAX_ROOMS; i++) {
|
|
|
|
|
ContactInfo c;
|
|
|
|
|
if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c) || c.type != ADV_TYPE_ROOM) continue;
|
|
|
|
|
bool fav = contactctl::favourite(c);
|
|
|
|
|
if (room_fav_only && !fav) continue;
|
|
|
|
|
if (fav_first && fav != (pass == 0)) continue;
|
|
|
|
|
memcpy(s_room_rows[nrooms], c.id.pub_key, PUB_KEY_SIZE);
|
|
|
|
|
char title[48], sub[64];
|
|
|
|
|
snprintf(title, sizeof(title), "%s%s", fav ? UI_SYMBOL_STAR " " : "", c.name);
|
|
|
|
|
if (_core->history.dmHistCountForContact(c.id.pub_key) > 0) {
|
|
|
|
|
const DmHistEntry& e = _core->history.dmAtPos(_core->history.dmHistEntryForContact(c.id.pub_key, 0));
|
|
|
|
|
snprintf(sub, sizeof(sub), "%s%s", e.outgoing ? "Me: " : "", e.text);
|
|
|
|
|
} else {
|
|
|
|
|
snprintf(sub, sizeof(sub), "%s", _core->rooms.isLoggedIn(c.id.pub_key) ? "Logged in" : "Tap to log in");
|
|
|
|
|
}
|
|
|
|
|
lv_obj_t* row = listRow(body, title, sub, onRoomRow, (void*)(uintptr_t)nrooms);
|
|
|
|
|
lv_obj_add_event_cb(row, onRoomRowHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)nrooms);
|
|
|
|
|
nrooms++;
|
|
|
|
|
}
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
2026-09-25 20:27:32 +02:00
|
|
|
if (nrooms == 0) label(body, room_fav_only ? "No favourite rooms" : "No room servers known", THEME_FONT_SMALL, theme::TEXT_MUTED);
|
2026-09-24 23:49:42 +02:00
|
|
|
|
|
|
|
|
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);
|
2026-09-25 20:27:32 +02:00
|
|
|
bool fav_only = _prefs && !_prefs->dm_show_all; // ui-new's default: favourites only
|
|
|
|
|
sectionWithFilter(body, "CONTACTS", fav_only, CF_CONTACTS);
|
2026-09-24 23:49:42 +02:00
|
|
|
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;
|
2026-09-25 20:27:32 +02:00
|
|
|
if (fav_only && !contactctl::favourite(c)) continue;
|
2026-09-24 23:49:42 +02:00
|
|
|
memcpy(s_contact_rows[rows], c.id.pub_key, PUB_KEY_SIZE);
|
|
|
|
|
listRow(body, c.name, NULL, onOpenContactRow, (void*)(uintptr_t)rows);
|
|
|
|
|
rows++;
|
|
|
|
|
}
|
2026-09-25 20:27:32 +02:00
|
|
|
if (rows == 0) label(body, fav_only ? "No favourites - tap All" : "No contacts yet", THEME_FONT_BODY, theme::TEXT_MUTED);
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-25 00:53:23 +02:00
|
|
|
// ── Nearby ────────────────────────────────────────────────────────────────────
|
|
|
|
|
// Contacts / live shares / heard adverts from NearbyModel, filtered by type
|
|
|
|
|
// chips, sorted by distance or recency. Tap a row for detail. Scan (nodes that
|
|
|
|
|
// answer a discover request right now) is a popup over the list with its own
|
|
|
|
|
// 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)
|
2026-09-25 19:59:00 +02:00
|
|
|
enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN };
|
2026-09-25 00:53:23 +02:00
|
|
|
|
|
|
|
|
static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
|
|
|
|
|
static void onNearbySort(lv_event_t* e) { (void)e; s_ui->toggleNearbySort(); }
|
|
|
|
|
static void onNearbyScan(lv_event_t* e) { (void)e; s_ui->startNearbyScan(); }
|
|
|
|
|
static void onNearbyRow(lv_event_t* e) { s_ui->openNode((int)(uintptr_t)lv_event_get_user_data(e)); }
|
|
|
|
|
static void onScanRow(lv_event_t* e) { s_ui->openScanNode((int)(uintptr_t)lv_event_get_user_data(e)); }
|
|
|
|
|
static void onScanClose(lv_event_t* e) { (void)e; s_ui->closeScanPopup(); }
|
|
|
|
|
static void onNodeAction(lv_event_t* e) { s_ui->nodeAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
|
|
|
|
|
|
|
|
|
|
static lv_obj_t* headerButton(lv_obj_t* hdr, const char* text, lv_event_cb_t cb, int right, lv_obj_t** label_out) {
|
|
|
|
|
lv_obj_t* b = lv_button_create(hdr);
|
|
|
|
|
lv_obj_set_size(b, LV_SIZE_CONTENT, 28);
|
|
|
|
|
lv_obj_set_style_pad_hor(b, 10, 0);
|
|
|
|
|
lv_obj_set_style_pad_ver(b, 0, 0);
|
|
|
|
|
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0);
|
|
|
|
|
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
|
|
|
|
|
lv_obj_set_style_shadow_width(b, 0, 0);
|
|
|
|
|
lv_obj_align(b, LV_ALIGN_RIGHT_MID, -right, 0);
|
|
|
|
|
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, NULL);
|
|
|
|
|
lv_obj_t* l = label(b, text, THEME_FONT_SMALL, theme::TEXT);
|
|
|
|
|
lv_obj_center(l);
|
|
|
|
|
if (label_out) *label_out = l;
|
|
|
|
|
return b;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::showNearby() {
|
|
|
|
|
_screen = SCR_NEARBY;
|
|
|
|
|
_scanning = false;
|
|
|
|
|
buildNearby(); // filter / sort persist
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::buildNearby() {
|
|
|
|
|
lv_obj_t* body = newScreen("Nearby", true);
|
|
|
|
|
lv_obj_set_style_pad_row(body, 4, 0);
|
|
|
|
|
if (_header) {
|
|
|
|
|
headerButton(_header, LV_SYMBOL_REFRESH " Scan", onNearbyScan, 4, NULL);
|
2026-09-25 19:20:42 +02:00
|
|
|
lv_obj_set_width(headerButton(_header, "", onNearbySort, 84, &_nearby_sort_lbl), 62); // fits "Recent" / "Dist"
|
|
|
|
|
headerButton(_header, UI_SYMBOL_MAP, onOpenNodesMap, 152, NULL); // the Nodes map
|
2026-09-25 00:53:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Type filter chips
|
|
|
|
|
_nearby_chips = lv_obj_create(body);
|
|
|
|
|
styleSurface(_nearby_chips, theme::BG);
|
|
|
|
|
lv_obj_remove_flag(_nearby_chips, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
|
lv_obj_set_size(_nearby_chips, LV_PCT(100), 26);
|
|
|
|
|
lv_obj_set_flex_flow(_nearby_chips, LV_FLEX_FLOW_ROW);
|
|
|
|
|
lv_obj_set_style_pad_column(_nearby_chips, 4, 0);
|
|
|
|
|
for (uint8_t f = 0; f < NearbyModel::F_COUNT; f++) {
|
|
|
|
|
lv_obj_t* c = lv_button_create(_nearby_chips);
|
|
|
|
|
lv_obj_set_height(c, 26);
|
|
|
|
|
lv_obj_set_flex_grow(c, 1);
|
|
|
|
|
lv_obj_set_style_pad_all(c, 0, 0);
|
|
|
|
|
lv_obj_set_style_radius(c, 13, 0);
|
|
|
|
|
lv_obj_set_style_shadow_width(c, 0, 0);
|
|
|
|
|
bool on = f == _nearby->filter();
|
|
|
|
|
lv_obj_set_style_bg_color(c, lv_color_hex(on ? theme::ACCENT : theme::SURFACE), 0);
|
|
|
|
|
lv_obj_add_event_cb(c, onNearbyChip, LV_EVENT_CLICKED, (void*)(uintptr_t)f);
|
|
|
|
|
lv_obj_center(label(c, NearbyModel::filterLabel(f), THEME_FONT_SMALL, on ? theme::BG : theme::TEXT));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_nearby_status = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
|
|
|
|
|
|
|
|
|
|
_nearby_list = lv_obj_create(body);
|
|
|
|
|
styleSurface(_nearby_list, theme::BG);
|
|
|
|
|
lv_obj_set_width(_nearby_list, LV_PCT(100));
|
|
|
|
|
lv_obj_set_flex_grow(_nearby_list, 1);
|
|
|
|
|
lv_obj_set_flex_flow(_nearby_list, LV_FLEX_FLOW_COLUMN);
|
|
|
|
|
lv_obj_set_style_pad_row(_nearby_list, theme::GAP, 0);
|
|
|
|
|
lv_obj_set_scrollbar_mode(_nearby_list, LV_SCROLLBAR_MODE_ACTIVE);
|
|
|
|
|
|
|
|
|
|
_nearby_sig = 0;
|
|
|
|
|
refreshNearbyList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::setNearbyFilter(uint8_t f) {
|
|
|
|
|
_nearby->setFilter(f);
|
|
|
|
|
buildNearby();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::toggleNearbySort() {
|
|
|
|
|
_nearby->setSortMode(_nearby->sortMode() == NearbyModel::SORT_DIST ? NearbyModel::SORT_TIME
|
|
|
|
|
: NearbyModel::SORT_DIST);
|
|
|
|
|
_nearby_sig = 0;
|
|
|
|
|
refreshNearbyList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::startNearbyScan() {
|
|
|
|
|
_scanning = true;
|
|
|
|
|
_scan_until_ms = millis() + 8000;
|
|
|
|
|
_next_nearby_ms = millis() + 250;
|
|
|
|
|
the_mesh.sendNodeDiscoverReq();
|
|
|
|
|
if (!_scan_overlay) showScanPopup();
|
|
|
|
|
else { _scan_sig = 0; refreshScanPopup(); }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Dimmed full-screen overlay (swallows taps) holding a panel with the results.
|
|
|
|
|
// A child of the current screen, so it goes away with it.
|
|
|
|
|
void UITask::showScanPopup() {
|
|
|
|
|
_scan_overlay = lv_obj_create(lv_screen_active());
|
|
|
|
|
lv_obj_remove_style_all(_scan_overlay);
|
|
|
|
|
lv_obj_set_size(_scan_overlay, LV_PCT(100), LV_PCT(100));
|
|
|
|
|
lv_obj_set_style_bg_color(_scan_overlay, lv_color_hex(0x000000), 0);
|
|
|
|
|
lv_obj_set_style_bg_opa(_scan_overlay, LV_OPA_60, 0);
|
|
|
|
|
lv_obj_add_flag(_scan_overlay, LV_OBJ_FLAG_CLICKABLE);
|
|
|
|
|
lv_obj_remove_flag(_scan_overlay, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
|
|
|
|
|
|
lv_obj_t* panel = lv_obj_create(_scan_overlay);
|
|
|
|
|
lv_obj_set_size(panel, lv_display_get_horizontal_resolution(NULL) - 16,
|
|
|
|
|
lv_display_get_vertical_resolution(NULL) - theme::STATUS_H - 12);
|
|
|
|
|
lv_obj_set_pos(panel, 8, theme::STATUS_H + 6);
|
|
|
|
|
lv_obj_set_style_bg_color(panel, lv_color_hex(theme::BG), 0);
|
|
|
|
|
lv_obj_set_style_border_color(panel, lv_color_hex(theme::ACCENT), 0);
|
|
|
|
|
lv_obj_set_style_border_width(panel, 1, 0);
|
|
|
|
|
lv_obj_set_style_radius(panel, theme::RADIUS, 0);
|
|
|
|
|
lv_obj_set_style_pad_all(panel, theme::PAD, 0);
|
|
|
|
|
lv_obj_set_style_pad_row(panel, 4, 0);
|
|
|
|
|
lv_obj_set_flex_flow(panel, LV_FLEX_FLOW_COLUMN);
|
|
|
|
|
lv_obj_remove_flag(panel, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
|
|
|
|
|
|
lv_obj_t* hdr = lv_obj_create(panel);
|
|
|
|
|
styleSurface(hdr, theme::BG);
|
|
|
|
|
lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
|
lv_obj_set_size(hdr, LV_PCT(100), 28);
|
|
|
|
|
lv_obj_align(label(hdr, "In range now", THEME_FONT_TITLE, theme::TEXT), LV_ALIGN_LEFT_MID, 0, 0);
|
|
|
|
|
headerButton(hdr, LV_SYMBOL_CLOSE, onScanClose, 0, NULL);
|
|
|
|
|
headerButton(hdr, LV_SYMBOL_REFRESH " Again", onNearbyScan, 44, NULL);
|
|
|
|
|
|
|
|
|
|
_scan_status = label(panel, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
|
|
|
|
|
_scan_list = lv_obj_create(panel);
|
|
|
|
|
styleSurface(_scan_list, theme::BG);
|
|
|
|
|
lv_obj_set_width(_scan_list, LV_PCT(100));
|
|
|
|
|
lv_obj_set_flex_grow(_scan_list, 1);
|
|
|
|
|
lv_obj_set_flex_flow(_scan_list, LV_FLEX_FLOW_COLUMN);
|
|
|
|
|
lv_obj_set_style_pad_row(_scan_list, theme::GAP, 0);
|
|
|
|
|
lv_obj_set_scrollbar_mode(_scan_list, LV_SCROLLBAR_MODE_ACTIVE);
|
|
|
|
|
|
|
|
|
|
_scan_sig = 0;
|
|
|
|
|
refreshScanPopup();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::closeScanPopup() {
|
|
|
|
|
if (_scan_overlay) lv_obj_delete_async(_scan_overlay); // may be closing from its own button
|
|
|
|
|
_scan_overlay = _scan_list = _scan_status = nullptr;
|
|
|
|
|
_scanning = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::refreshScanPopup() {
|
|
|
|
|
if (!_scan_list) return;
|
|
|
|
|
_scan->refreshScan();
|
|
|
|
|
int n = _scan->count();
|
|
|
|
|
if (_scanning) lv_label_set_text_fmt(_scan_status, LV_SYMBOL_REFRESH " Listening for replies... %d", n);
|
|
|
|
|
else lv_label_set_text_fmt(_scan_status, "%d node%s answered the discover request", n, n == 1 ? "" : "s");
|
|
|
|
|
|
|
|
|
|
uint32_t sig = (uint32_t)n + (_scanning ? 0x10000u : 0);
|
|
|
|
|
for (int i = 0; i < n; i++) {
|
|
|
|
|
const NearbyModel::Entry& e = _scan->at(i);
|
|
|
|
|
sig = sig * 31 + e.rssi * 7 + e.snr_x4 + e.is_known;
|
|
|
|
|
for (int k = 0; k < 4; k++) sig = sig * 31 + e.pub_key[k];
|
|
|
|
|
}
|
|
|
|
|
if (sig == _scan_sig) return;
|
|
|
|
|
_scan_sig = sig;
|
|
|
|
|
|
|
|
|
|
lv_obj_clean(_scan_list);
|
|
|
|
|
for (int i = 0; i < n; i++) {
|
|
|
|
|
const NearbyModel::Entry& e = _scan->at(i);
|
|
|
|
|
char title[40], sub[48], right[12];
|
|
|
|
|
if (e.name[0]) snprintf(title, sizeof(title), "%s", e.name);
|
|
|
|
|
else snprintf(title, sizeof(title), "%s %02X%02X%02X%02X", NearbyModel::typeName(e.type),
|
|
|
|
|
e.pub_key[0], e.pub_key[1], e.pub_key[2], e.pub_key[3]);
|
|
|
|
|
snprintf(sub, sizeof(sub), "%s - SNR %.1f / %.1f%s", NearbyModel::typeName(e.type),
|
|
|
|
|
e.snr_x4 / 4.0f, e.remote_snr_x4 / 4.0f, e.is_known ? "" : " - new");
|
|
|
|
|
snprintf(right, sizeof(right), "%d dBm", e.rssi);
|
|
|
|
|
lv_obj_t* row = listRow(_scan_list, title, sub, onScanRow, (void*)(uintptr_t)i);
|
|
|
|
|
lv_obj_align(label(row, right, THEME_FONT_SMALL, theme::TEXT_MUTED), LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
|
|
|
|
|
}
|
|
|
|
|
if (n == 0) {
|
|
|
|
|
lv_obj_t* l = label(_scan_list, _scanning ? "Repeaters and rooms in range will answer."
|
|
|
|
|
: "Nobody answered. Try again later or move.",
|
|
|
|
|
THEME_FONT_BODY, theme::TEXT_MUTED);
|
|
|
|
|
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
|
|
|
|
|
lv_obj_set_width(l, LV_PCT(100));
|
|
|
|
|
lv_obj_set_style_pad_top(l, 8, 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::openScanNode(int row) {
|
|
|
|
|
if (row < 0 || row >= _scan->count()) return;
|
|
|
|
|
s_node = _scan->at(row);
|
|
|
|
|
_node_from_scan = true;
|
2026-09-25 10:57:46 +02:00
|
|
|
_node_from_map = false;
|
2026-09-25 00:53:23 +02:00
|
|
|
_scanning = false;
|
|
|
|
|
_pinging = false;
|
|
|
|
|
_delete_armed_ms = 0;
|
|
|
|
|
_screen = SCR_NODE;
|
|
|
|
|
buildNode();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Changes whenever a rebuild would show something different.
|
|
|
|
|
uint32_t UITask::nearbySignature() const {
|
|
|
|
|
uint32_t sig = (uint32_t)_nearby->count() * 2654435761u + _nearby->sortMode() + (_scanning ? 7 : 0);
|
|
|
|
|
for (int i = 0; i < _nearby->count(); i++) {
|
|
|
|
|
const NearbyModel::Entry& e = _nearby->at(i);
|
|
|
|
|
sig = sig * 31 + e.contact_idx + (uint32_t)e.lastmod + (uint32_t)(e.dist_km * 100) + e.rssi + e.fav;
|
|
|
|
|
for (const char* p = e.name; *p; p++) sig = sig * 31 + (uint8_t)*p;
|
|
|
|
|
}
|
|
|
|
|
// Ages are shown in minutes, so let the list re-render once a minute anyway.
|
|
|
|
|
return sig + rtc_clock.getCurrentTime() / 60;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::refreshNearbyList() {
|
|
|
|
|
if (!_nearby_list) return;
|
|
|
|
|
_nearby->refreshModel();
|
|
|
|
|
uint32_t sig = nearbySignature();
|
|
|
|
|
int n = _nearby->count();
|
|
|
|
|
|
|
|
|
|
if (_nearby_sort_lbl)
|
|
|
|
|
lv_label_set_text(_nearby_sort_lbl, _nearby->sortMode() == NearbyModel::SORT_TIME ? "Recent" : "Dist");
|
|
|
|
|
int32_t lat, lon;
|
|
|
|
|
bool gps = _nearby->ownPosition(lat, lon);
|
|
|
|
|
lv_label_set_text_fmt(_nearby_status, "%d node%s%s", n, n == 1 ? "" : "s",
|
|
|
|
|
gps ? "" : " - no GPS fix, distances unknown");
|
|
|
|
|
if (sig == _nearby_sig) return;
|
|
|
|
|
_nearby_sig = sig;
|
|
|
|
|
|
|
|
|
|
int32_t scroll = lv_obj_get_scroll_y(_nearby_list);
|
|
|
|
|
lv_obj_clean(_nearby_list);
|
|
|
|
|
uint32_t now = rtc_clock.getCurrentTime();
|
|
|
|
|
bool imperial = _prefs && _prefs->units_imperial;
|
|
|
|
|
for (int i = 0; i < n; i++) {
|
|
|
|
|
const NearbyModel::Entry& e = _nearby->at(i);
|
|
|
|
|
char title[48], sub[48], right[16] = "";
|
|
|
|
|
const char* name = e.name[0] ? e.name : "(unknown)";
|
|
|
|
|
snprintf(title, sizeof(title), "%s%s", e.fav ? UI_SYMBOL_STAR " " : "", name);
|
|
|
|
|
char age[8];
|
|
|
|
|
geo::fmtAgeShort(age, sizeof(age), now, e.lastmod);
|
|
|
|
|
if (e.dist_km >= 0.0f) geo::fmtDist(right, sizeof(right), e.dist_km, imperial);
|
|
|
|
|
else if (age[0]) snprintf(right, sizeof(right), "%s", age);
|
|
|
|
|
const char* kind = e.contact_idx >= 0 ? NearbyModel::typeName(e.type) : "not a contact";
|
|
|
|
|
snprintf(sub, sizeof(sub), "%s%s%s%s", kind, e.is_live ? " - live" : "",
|
|
|
|
|
(e.dist_km >= 0.0f && age[0]) ? " - " : "", (e.dist_km >= 0.0f && age[0]) ? age : "");
|
|
|
|
|
lv_obj_t* row = listRow(_nearby_list, title, sub, onNearbyRow, (void*)(uintptr_t)i);
|
|
|
|
|
if (e.fav) lv_obj_set_style_text_color(lv_obj_get_child(row, 0), lv_color_hex(theme::ACCENT), 0);
|
|
|
|
|
if (right[0]) {
|
|
|
|
|
lv_obj_t* r = label(row, right, THEME_FONT_SMALL, e.is_live ? theme::OK : theme::TEXT_MUTED);
|
|
|
|
|
lv_obj_align(r, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (n == 0) {
|
|
|
|
|
lv_obj_t* l = label(_nearby_list, "Nobody here yet. Tap Scan to look around.",
|
|
|
|
|
THEME_FONT_BODY, theme::TEXT_MUTED);
|
|
|
|
|
lv_obj_set_style_pad_top(l, 12, 0);
|
|
|
|
|
}
|
|
|
|
|
lv_obj_update_layout(_nearby_list);
|
|
|
|
|
lv_obj_scroll_to_y(_nearby_list, scroll, LV_ANIM_OFF);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Node detail ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
void UITask::openNode(int row) {
|
|
|
|
|
if (row < 0 || row >= _nearby->count()) return;
|
|
|
|
|
s_node = _nearby->at(row);
|
|
|
|
|
_node_from_scan = false;
|
2026-09-25 10:57:46 +02:00
|
|
|
_node_from_map = false;
|
2026-09-25 00:53:23 +02:00
|
|
|
_pinging = false;
|
|
|
|
|
_delete_armed_ms = 0;
|
|
|
|
|
_screen = SCR_NODE;
|
|
|
|
|
buildNode();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static lv_obj_t* actionButton(lv_obj_t* parent, const char* text, uint8_t action, bool accent) {
|
|
|
|
|
lv_obj_t* b = lv_button_create(parent);
|
|
|
|
|
lv_obj_set_height(b, 40);
|
|
|
|
|
lv_obj_set_flex_grow(b, 1);
|
|
|
|
|
lv_obj_set_style_pad_hor(b, 4, 0);
|
|
|
|
|
lv_obj_set_style_radius(b, theme::RADIUS, 0);
|
|
|
|
|
lv_obj_set_style_shadow_width(b, 0, 0);
|
|
|
|
|
lv_obj_set_style_bg_color(b, lv_color_hex(accent ? theme::ACCENT_DIM : theme::SURFACE), 0);
|
|
|
|
|
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
|
|
|
|
|
lv_obj_add_event_cb(b, onNodeAction, LV_EVENT_CLICKED, (void*)(uintptr_t)action);
|
|
|
|
|
lv_obj_t* l = label(b, text, THEME_FONT_SMALL, theme::TEXT);
|
|
|
|
|
lv_obj_center(l);
|
|
|
|
|
return l;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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* acts = lv_obj_create(body);
|
|
|
|
|
styleSurface(acts, theme::BG);
|
|
|
|
|
lv_obj_remove_flag(acts, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
|
lv_obj_set_size(acts, LV_PCT(100), LV_SIZE_CONTENT);
|
|
|
|
|
lv_obj_set_flex_flow(acts, LV_FLEX_FLOW_ROW);
|
|
|
|
|
lv_obj_set_style_pad_column(acts, theme::GAP, 0);
|
|
|
|
|
bool contact = e.contact_idx >= 0;
|
|
|
|
|
if (contact && e.type == ADV_TYPE_CHAT) actionButton(acts, LV_SYMBOL_ENVELOPE " Message", NODE_MSG, true);
|
|
|
|
|
if (e.has_key) actionButton(acts, LV_SYMBOL_LOOP " Ping", NODE_PING, false);
|
2026-09-25 19:20:42 +02:00
|
|
|
if (e.lat_e6 != 0 || e.lon_e6 != 0) actionButton(acts, UI_SYMBOL_COMPASS, NODE_NAV, false);
|
2026-09-25 19:59:00 +02:00
|
|
|
bool admin = contact && (e.type == ADV_TYPE_REPEATER || e.type == ADV_TYPE_ROOM);
|
|
|
|
|
if (contact) actionButton(acts, admin ? UI_SYMBOL_STAR : e.fav ? UI_SYMBOL_STAR " Unfav" : UI_SYMBOL_STAR " Fav", NODE_FAV, e.fav && admin);
|
|
|
|
|
if (admin) actionButton(acts, LV_SYMBOL_SETTINGS " Admin", NODE_ADMIN, false);
|
2026-09-25 00:53:23 +02:00
|
|
|
if (!contact && e.has_key && !e.is_known) actionButton(acts, LV_SYMBOL_PLUS " Add", NODE_ADD, true);
|
|
|
|
|
if (contact) _node_delete_lbl = actionButton(acts, LV_SYMBOL_TRASH, NODE_DELETE, false);
|
|
|
|
|
|
|
|
|
|
refreshNode();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::refreshNode() {
|
|
|
|
|
if (!_node_info) return;
|
|
|
|
|
const NearbyModel::Entry& e = s_node;
|
|
|
|
|
// Refresh position / age / signal from its model while the node is still listed.
|
|
|
|
|
NearbyModel* model = _node_from_scan ? _scan : _nearby;
|
|
|
|
|
model->refreshModel();
|
|
|
|
|
for (int i = 0; i < model->count(); i++) {
|
|
|
|
|
const NearbyModel::Entry& m = model->at(i);
|
|
|
|
|
bool same = e.has_key ? (m.has_key && memcmp(m.pub_key, e.pub_key, PUB_KEY_SIZE) == 0)
|
|
|
|
|
: (e.contact_idx >= 0) ? m.contact_idx == e.contact_idx
|
|
|
|
|
: strncmp(m.name, e.name, sizeof(m.name)) == 0;
|
|
|
|
|
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" : "");
|
|
|
|
|
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) {
|
|
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
o += snprintf(buf + o, sizeof(buf) - o, "\n%.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");
|
|
|
|
|
}
|
|
|
|
|
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 (_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);
|
|
|
|
|
|
|
|
|
|
// Ping result (PingEngine releases its slot on reply; the view owns the timeout)
|
|
|
|
|
if (_pinging) {
|
|
|
|
|
int16_t out = 0, back = 0; uint32_t rtt = 0;
|
|
|
|
|
_core->ping.getResult(out, back, rtt);
|
|
|
|
|
if (!_core->ping.isActive() && (out || back || rtt)) {
|
|
|
|
|
lv_label_set_text_fmt(_node_ping, "Ping %lu ms - SNR out %.1f back %.1f", (unsigned long)rtt,
|
|
|
|
|
out / 4.0f, back / 4.0f);
|
|
|
|
|
_pinging = false;
|
|
|
|
|
} else if (millis() - _ping_started_ms > 3000) {
|
|
|
|
|
_core->ping.clear();
|
|
|
|
|
lv_label_set_text(_node_ping, "Ping: no reply");
|
|
|
|
|
_pinging = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if (_node_delete_lbl && _delete_armed_ms && millis() - _delete_armed_ms > 3000) {
|
|
|
|
|
_delete_armed_ms = 0;
|
|
|
|
|
lv_label_set_text(_node_delete_lbl, LV_SYMBOL_TRASH);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::nodeAction(uint8_t action) {
|
|
|
|
|
NearbyModel::Entry& e = s_node;
|
|
|
|
|
switch (action) {
|
|
|
|
|
case NODE_MSG:
|
|
|
|
|
openDM(e.pub_key);
|
|
|
|
|
break;
|
|
|
|
|
case NODE_PING: {
|
|
|
|
|
if (_pinging) break;
|
|
|
|
|
PingEngine::StartResult r = _core->ping.start(e.pub_key);
|
|
|
|
|
if (r == PingEngine::STARTED) {
|
|
|
|
|
_pinging = true;
|
|
|
|
|
_ping_started_ms = millis();
|
|
|
|
|
_next_nearby_ms = millis() + 250;
|
|
|
|
|
lv_label_set_text(_node_ping, "Pinging...");
|
|
|
|
|
} else {
|
|
|
|
|
lv_label_set_text(_node_ping, r == PingEngine::UNSUPPORTED ? "Ping needs 1-2 byte path hashes"
|
|
|
|
|
: "Ping failed");
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
case NODE_FAV:
|
|
|
|
|
if (the_mesh.setContactFavourite(e.pub_key, !e.fav)) {
|
|
|
|
|
e.fav = !e.fav;
|
|
|
|
|
buildNode();
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
case NODE_ADD:
|
|
|
|
|
if (the_mesh.addDiscoveredContact(e.pub_key, e.name, e.type)) {
|
|
|
|
|
showToast("Contact added");
|
|
|
|
|
_screen = SCR_NEARBY;
|
|
|
|
|
buildNearby();
|
|
|
|
|
} else {
|
|
|
|
|
showToast("Contacts full");
|
|
|
|
|
}
|
|
|
|
|
break;
|
2026-09-25 19:20:42 +02:00
|
|
|
case NODE_NAV:
|
|
|
|
|
// Followed by key when there is one (the Locator resolves live share,
|
|
|
|
|
// then advert position); otherwise a fixed point where it was seen.
|
|
|
|
|
if (e.has_prefix) navToNode(e.pub_key, e.lat_e6, e.lon_e6, e.name[0] ? e.name : "Node");
|
|
|
|
|
else {
|
|
|
|
|
_core->locator.setTarget(0, nullptr, e.lat_e6, e.lon_e6, e.name[0] ? e.name : "Node");
|
|
|
|
|
the_mesh.savePrefs();
|
|
|
|
|
openMap(true);
|
|
|
|
|
navFrameTarget();
|
|
|
|
|
}
|
|
|
|
|
break;
|
2026-09-25 19:59:00 +02:00
|
|
|
case NODE_ADMIN:
|
|
|
|
|
openAdmin(e.pub_key);
|
|
|
|
|
break;
|
2026-09-25 00:53:23 +02:00
|
|
|
case NODE_DELETE:
|
|
|
|
|
if (!_delete_armed_ms) { // destructive: second tap within 3 s confirms
|
|
|
|
|
_delete_armed_ms = millis();
|
|
|
|
|
if (_delete_armed_ms == 0) _delete_armed_ms = 1;
|
|
|
|
|
lv_label_set_text(_node_delete_lbl, "Delete?");
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if (the_mesh.deleteContactByKey(e.pub_key)) {
|
|
|
|
|
showToast("Contact deleted");
|
|
|
|
|
_screen = SCR_NEARBY;
|
|
|
|
|
buildNearby();
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 10:57:46 +02:00
|
|
|
#include "MapScreen.h"
|
2026-09-25 19:20:42 +02:00
|
|
|
#include "NavMap.h"
|
|
|
|
|
#include "ClockScreen.h"
|
|
|
|
|
#include "RadioScreen.h"
|
2026-09-25 11:26:44 +02:00
|
|
|
#include "WifiScreen.h"
|
2026-09-25 19:42:44 +02:00
|
|
|
#include "ChannelScreen.h"
|
2026-09-25 19:59:00 +02:00
|
|
|
#include "AdminScreen.h"
|
2026-09-25 20:09:02 +02:00
|
|
|
#include "BotScreen.h"
|
2026-09-25 10:57:46 +02:00
|
|
|
|
2026-09-24 23:49:42 +02:00
|
|
|
// ── 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();
|
2026-09-25 00:36:26 +02:00
|
|
|
else if (code == LV_EVENT_CANCEL) s_ui->setKeyboardVisible(false);
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
|
2026-09-25 00:08:15 +02:00
|
|
|
// Compose limit is in UTF-8 bytes (the over-the-air limit), not characters:
|
|
|
|
|
// Cyrillic / Greek / accented letters take two bytes each. Leaves room for the
|
|
|
|
|
// "Name: " prefix a channel send adds.
|
|
|
|
|
static const size_t COMPOSE_MAX_BYTES = MAX_TEXT_LEN - 40;
|
|
|
|
|
|
|
|
|
|
static void onComposeInsert(lv_event_t* e) {
|
|
|
|
|
lv_obj_t* ta = (lv_obj_t*)lv_event_get_target(e);
|
|
|
|
|
const char* ins = (const char*)lv_event_get_param(e);
|
|
|
|
|
if (ins && strlen(lv_textarea_get_text(ta)) + strlen(ins) > COMPOSE_MAX_BYTES)
|
|
|
|
|
lv_textarea_set_insert_replace(ta, ""); // reject: would exceed the byte limit
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 00:36:26 +02:00
|
|
|
static void onComposeClicked(lv_event_t* e) { (void)e; s_ui->setKeyboardVisible(true); }
|
|
|
|
|
|
|
|
|
|
// Keyboard up: the screen header goes away and the body takes its 32 px, so
|
|
|
|
|
// a line or two of the conversation stays visible above the compose field
|
|
|
|
|
// (240 px can't fit header + list + field + keys). The field is FOCUSED
|
|
|
|
|
// while the keyboard is up -- that is what makes LVGL draw its cursor.
|
|
|
|
|
void UITask::setKeyboardVisible(bool show) {
|
|
|
|
|
if (!_keyboard || !_compose_ta) return;
|
|
|
|
|
if (show == !lv_obj_has_flag(_keyboard, LV_OBJ_FLAG_HIDDEN)) return;
|
|
|
|
|
if (show) {
|
|
|
|
|
lv_obj_remove_flag(_keyboard, LV_OBJ_FLAG_HIDDEN);
|
|
|
|
|
lv_obj_add_state(_compose_ta, LV_STATE_FOCUSED);
|
|
|
|
|
} else {
|
|
|
|
|
lv_obj_add_flag(_keyboard, LV_OBJ_FLAG_HIDDEN);
|
|
|
|
|
lv_obj_remove_state(_compose_ta, LV_STATE_FOCUSED);
|
|
|
|
|
}
|
|
|
|
|
if (_header && _body) {
|
|
|
|
|
int top = theme::STATUS_H + (show ? 0 : lv_obj_get_height(_header));
|
|
|
|
|
if (show) lv_obj_add_flag(_header, LV_OBJ_FLAG_HIDDEN);
|
|
|
|
|
else lv_obj_remove_flag(_header, LV_OBJ_FLAG_HIDDEN);
|
|
|
|
|
lv_obj_set_pos(_body, 0, top);
|
|
|
|
|
lv_obj_set_height(_body, lv_display_get_vertical_resolution(NULL) - top);
|
|
|
|
|
}
|
|
|
|
|
if (_thread_list) { // keep the newest message in view
|
|
|
|
|
lv_obj_update_layout(_thread_list);
|
|
|
|
|
lv_obj_scroll_to_y(_thread_list, LV_COORD_MAX, LV_ANIM_OFF);
|
|
|
|
|
}
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-09-25 19:42:44 +02:00
|
|
|
if (the_mesh.getChannel(_thread_channel, ch)) snprintf(title, sizeof(title), "%s%s", ch.name[0] == '#' ? "" : "# ", ch.name);
|
2026-09-24 23:49:42 +02:00
|
|
|
else snprintf(title, sizeof(title), "Channel %d", _thread_channel);
|
|
|
|
|
} else {
|
|
|
|
|
contactName(_thread_key, title, sizeof(title));
|
|
|
|
|
ContactInfo c;
|
2026-09-25 20:27:32 +02:00
|
|
|
can_send = MessageHistory::contactByPrefix(_thread_key, c) && (c.type == ADV_TYPE_CHAT || c.type == ADV_TYPE_ROOM);
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
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);
|
2026-09-25 20:27:32 +02:00
|
|
|
if (_header && (_thread_is_channel || can_send)) { // channel / conversation options
|
2026-09-25 19:42:44 +02:00
|
|
|
lv_obj_set_width(lv_obj_get_child(_header, 1), 200); // title, clear of the button
|
2026-09-25 20:27:32 +02:00
|
|
|
headerButton(_header, LV_SYMBOL_SETTINGS, _thread_is_channel ? onChanThreadMenu : onConvThreadMenu, 4, NULL);
|
2026-09-25 19:42:44 +02:00
|
|
|
}
|
2026-09-24 23:49:42 +02:00
|
|
|
|
|
|
|
|
_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);
|
2026-09-25 00:36:26 +02:00
|
|
|
lv_obj_set_scrollbar_mode(_thread_list, LV_SCROLLBAR_MODE_ACTIVE); // only while scrolling
|
2026-09-24 23:49:42 +02:00
|
|
|
|
|
|
|
|
_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);
|
2026-09-25 00:36:26 +02:00
|
|
|
lv_obj_set_size(bar, LV_PCT(100), 42);
|
2026-09-24 23:49:42 +02:00
|
|
|
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");
|
2026-09-25 00:08:15 +02:00
|
|
|
lv_obj_add_event_cb(_compose_ta, onComposeInsert, LV_EVENT_INSERT, NULL);
|
2026-09-25 00:36:26 +02:00
|
|
|
lv_obj_set_size(_compose_ta, LV_PCT(100), 34);
|
2026-09-24 23:49:42 +02:00
|
|
|
lv_obj_align(_compose_ta, LV_ALIGN_LEFT_MID, 0, 0);
|
2026-09-25 00:36:26 +02:00
|
|
|
// The theme's padding leaves less than one line inside 36 px, which makes
|
|
|
|
|
// the field scroll vertically (text jumps as it's typed). Pad so exactly one
|
|
|
|
|
// body line (20 px) fits: 34 = 2*1 border + 2*6 pad + 20.
|
|
|
|
|
lv_obj_set_style_border_width(_compose_ta, 1, 0);
|
|
|
|
|
lv_obj_set_style_pad_ver(_compose_ta, 6, 0);
|
|
|
|
|
lv_obj_set_style_pad_hor(_compose_ta, 10, 0);
|
|
|
|
|
lv_obj_set_scrollbar_mode(_compose_ta, LV_SCROLLBAR_MODE_OFF);
|
|
|
|
|
// Cursor: the theme draws it only while FOCUSED (set while the keyboard is up).
|
|
|
|
|
lv_obj_set_style_border_color(_compose_ta, lv_color_hex(theme::ACCENT), LV_PART_CURSOR | LV_STATE_FOCUSED);
|
|
|
|
|
lv_obj_set_style_border_width(_compose_ta, 2, LV_PART_CURSOR | LV_STATE_FOCUSED);
|
2026-09-24 23:49:42 +02:00
|
|
|
|
|
|
|
|
// 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.
|
2026-09-25 00:08:15 +02:00
|
|
|
_keyboard = kb::create(body, _prefs); // phone-style, scripts from prefs, hold for accents (Keyboard.h)
|
2026-09-25 00:36:26 +02:00
|
|
|
lv_obj_set_size(_keyboard, LV_PCT(100), 124);
|
2026-09-24 23:49:42 +02:00
|
|
|
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();
|
2026-09-25 19:20:42 +02:00
|
|
|
if (_compose_ta && _share_text[0]) { // shareToMessage(): the text waits in the field
|
|
|
|
|
lv_textarea_set_text(_compose_ta, _share_text);
|
|
|
|
|
_share_text[0] = '\0';
|
|
|
|
|
lv_obj_update_layout(lv_screen_active());
|
|
|
|
|
setKeyboardVisible(true);
|
|
|
|
|
}
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 19:20:42 +02:00
|
|
|
// Positions found in the shown messages ([WAY] / [LOC] / plain "lat,lon"),
|
|
|
|
|
// for the Go / Save buttons under such a bubble.
|
|
|
|
|
struct MsgLoc { int32_t lat, lon; char label[WAYPOINT_LABEL_LEN * 2]; };
|
|
|
|
|
static const int THREAD_MAX_SHOWN = 30;
|
|
|
|
|
static MsgLoc s_msg_locs[THREAD_MAX_SHOWN];
|
|
|
|
|
static int s_msg_loc_n = 0;
|
|
|
|
|
|
2026-09-25 20:27:32 +02:00
|
|
|
// What a held bubble is about: its history ring entry, sender, position slot.
|
|
|
|
|
struct MsgMeta { int pos; bool channel; bool own; int loc; char from[32]; };
|
|
|
|
|
static MsgMeta s_msg_meta[THREAD_MAX_SHOWN];
|
|
|
|
|
static int s_msg_meta_n = 0;
|
|
|
|
|
|
|
|
|
|
static int noteMsgMeta(int pos, bool channel, bool own, int loc, const char* from) {
|
|
|
|
|
if (s_msg_meta_n >= THREAD_MAX_SHOWN) return -1;
|
|
|
|
|
MsgMeta& m = s_msg_meta[s_msg_meta_n];
|
|
|
|
|
m.pos = pos; m.channel = channel; m.own = own; m.loc = loc;
|
|
|
|
|
snprintf(m.from, sizeof(m.from), "%s", from ? from : "");
|
|
|
|
|
return s_msg_meta_n++;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 19:20:42 +02:00
|
|
|
static void onMsgLoc(lv_event_t* e) {
|
|
|
|
|
uintptr_t v = (uintptr_t)lv_event_get_user_data(e);
|
|
|
|
|
s_ui->messageLocationAction((int)(v >> 1), (v & 1) != 0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void msgLocButton(lv_obj_t* parent, const char* text, int idx, bool save, bool accent) {
|
|
|
|
|
lv_obj_t* b = lv_button_create(parent);
|
|
|
|
|
lv_obj_set_size(b, LV_SIZE_CONTENT, 28);
|
|
|
|
|
lv_obj_set_style_pad_hor(b, 10, 0);
|
|
|
|
|
lv_obj_set_style_pad_ver(b, 0, 0);
|
|
|
|
|
lv_obj_set_style_radius(b, 14, 0);
|
|
|
|
|
lv_obj_set_style_shadow_width(b, 0, 0);
|
|
|
|
|
lv_obj_set_style_bg_color(b, lv_color_hex(accent ? theme::ACCENT : theme::SURFACE_2), 0);
|
|
|
|
|
lv_obj_add_event_cb(b, onMsgLoc, LV_EVENT_CLICKED, (void*)(uintptr_t)((idx << 1) | (save ? 1 : 0)));
|
|
|
|
|
lv_obj_center(label(b, text, THEME_FONT_SMALL, accent ? theme::BG : theme::TEXT));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 23:49:42 +02:00
|
|
|
// One message bubble. Own messages right-aligned in amber, others left.
|
2026-09-25 19:20:42 +02:00
|
|
|
// loc_idx >= 0: the text carries a position (s_msg_locs[loc_idx]).
|
2026-09-24 23:49:42 +02:00
|
|
|
static void bubble(lv_obj_t* list, const char* from, const char* text, bool own,
|
2026-09-25 20:27:32 +02:00
|
|
|
uint32_t ts, const char* status, uint32_t status_col, int loc_idx = -1, int meta_idx = -1) {
|
2026-09-24 23:49:42 +02:00
|
|
|
// 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);
|
2026-09-25 20:27:32 +02:00
|
|
|
if (meta_idx >= 0) { // hold: reply / path / target (ConversationScreen.h)
|
|
|
|
|
lv_obj_add_event_cb(b, onMsgHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)meta_idx);
|
|
|
|
|
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
|
|
|
|
|
} else {
|
|
|
|
|
lv_obj_remove_flag(b, LV_OBJ_FLAG_CLICKABLE);
|
|
|
|
|
}
|
2026-09-24 23:49:42 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-09-25 00:36:26 +02:00
|
|
|
char meta[32];
|
|
|
|
|
formatAge(meta, sizeof(meta), ts);
|
|
|
|
|
|
|
|
|
|
// Channel messages: "Sender 5m" on one line above the text, which keeps
|
|
|
|
|
// the bubble two lines tall -- matters with the keyboard up.
|
|
|
|
|
bool meta_in_header = from && from[0] && !status;
|
|
|
|
|
if (from && from[0]) {
|
|
|
|
|
lv_obj_t* hdr = lv_obj_create(b);
|
|
|
|
|
styleSurface(hdr, theme::BG);
|
|
|
|
|
lv_obj_set_style_bg_opa(hdr, LV_OPA_TRANSP, 0);
|
|
|
|
|
lv_obj_remove_flag(hdr, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
|
lv_obj_remove_flag(hdr, LV_OBJ_FLAG_CLICKABLE);
|
|
|
|
|
lv_obj_set_size(hdr, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
|
|
|
|
lv_obj_set_flex_flow(hdr, LV_FLEX_FLOW_ROW);
|
|
|
|
|
lv_obj_set_style_pad_column(hdr, 8, 0);
|
|
|
|
|
label(hdr, from, THEME_FONT_SMALL, theme::ACCENT); // names are <= 31 chars: fits the bubble
|
|
|
|
|
if (meta_in_header) label(hdr, meta, THEME_FONT_SMALL, theme::TEXT_MUTED);
|
|
|
|
|
}
|
2026-09-24 23:49:42 +02:00
|
|
|
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);
|
|
|
|
|
|
2026-09-25 19:20:42 +02:00
|
|
|
if (loc_idx >= 0) { // position: navigate there / keep it as a waypoint
|
|
|
|
|
lv_obj_t* acts = lv_obj_create(b);
|
|
|
|
|
styleSurface(acts, theme::BG);
|
|
|
|
|
lv_obj_set_style_bg_opa(acts, LV_OPA_TRANSP, 0);
|
|
|
|
|
lv_obj_remove_flag(acts, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
|
lv_obj_remove_flag(acts, LV_OBJ_FLAG_CLICKABLE);
|
|
|
|
|
lv_obj_set_size(acts, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
|
|
|
|
|
lv_obj_set_flex_flow(acts, LV_FLEX_FLOW_ROW);
|
|
|
|
|
lv_obj_set_style_pad_column(acts, 6, 0);
|
|
|
|
|
lv_obj_set_style_pad_top(acts, 2, 0);
|
|
|
|
|
msgLocButton(acts, UI_SYMBOL_COMPASS " Go", loc_idx, false, true);
|
|
|
|
|
msgLocButton(acts, UI_SYMBOL_FLAG " Save", loc_idx, true, false);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 00:36:26 +02:00
|
|
|
if (!meta_in_header) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 19:20:42 +02:00
|
|
|
// Remember the position in `text` (if any) for its bubble's buttons; label
|
|
|
|
|
// from a [WAY] tag, else the sender. Returns the slot or -1.
|
|
|
|
|
static int noteMsgLocation(const char* text, const char* sender) {
|
|
|
|
|
if (s_msg_loc_n >= THREAD_MAX_SHOWN) return -1;
|
|
|
|
|
MsgLoc& m = s_msg_locs[s_msg_loc_n];
|
|
|
|
|
if (!geo::parseLatLon(text, m.lat, m.lon, m.label, sizeof(m.label))) return -1;
|
|
|
|
|
if (!m.label[0]) snprintf(m.label, sizeof(m.label), "%s", sender && sender[0] ? sender : "Msg loc");
|
|
|
|
|
return s_msg_loc_n++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::messageLocationAction(int idx, bool save) {
|
|
|
|
|
if (idx < 0 || idx >= s_msg_loc_n) return;
|
|
|
|
|
const MsgLoc& m = s_msg_locs[idx];
|
|
|
|
|
if (save) {
|
|
|
|
|
if (_core->waypoints.full()) { showToast("Waypoints full (16)"); return; }
|
|
|
|
|
if (_core->waypoints.add(m.lat, m.lon, rtc_clock.getCurrentTime(), m.label)) {
|
|
|
|
|
char t[48];
|
|
|
|
|
snprintf(t, sizeof(t), "Saved %s", _core->waypoints.at(_core->waypoints.count() - 1).label);
|
|
|
|
|
showToast(t);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// A place snapshotted from the text (kind 0): nothing to keep re-resolving.
|
|
|
|
|
navSetTarget(0, nullptr, m.lat, m.lon, m.label);
|
|
|
|
|
openMap(true);
|
|
|
|
|
navFrameTarget();
|
|
|
|
|
refreshNavBar();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-24 23:49:42 +02:00
|
|
|
void UITask::refreshThread() {
|
|
|
|
|
if (!_thread_list) return;
|
|
|
|
|
_thread_dirty = false;
|
|
|
|
|
_thread_sig = threadSignature();
|
|
|
|
|
lv_obj_clean(_thread_list);
|
|
|
|
|
const MessageHistory& h = _core->history;
|
2026-09-25 19:20:42 +02:00
|
|
|
const int MAX_SHOWN = THREAD_MAX_SHOWN;
|
|
|
|
|
s_msg_loc_n = 0;
|
2026-09-25 20:27:32 +02:00
|
|
|
s_msg_meta_n = 0;
|
2026-09-24 23:49:42 +02:00
|
|
|
|
|
|
|
|
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
|
2026-09-25 20:27:32 +02:00
|
|
|
int pos = h.histEntryForChannel(_thread_channel, j);
|
|
|
|
|
const ChHistEntry& e = h.chAtPos(pos);
|
2026-09-24 23:49:42 +02:00
|
|
|
// 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";
|
2026-09-25 20:27:32 +02:00
|
|
|
int loc = own ? -1 : noteMsgLocation(body, from);
|
2026-09-25 19:20:42 +02:00
|
|
|
bubble(_thread_list, own ? NULL : from, body, own, e.timestamp, st, col,
|
2026-09-25 20:27:32 +02:00
|
|
|
loc, noteMsgMeta(pos, true, own, loc, own ? "" : from));
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
int n = h.dmHistCountForContact(_thread_key);
|
|
|
|
|
if (n > MAX_SHOWN) n = MAX_SHOWN;
|
2026-09-25 20:27:32 +02:00
|
|
|
ContactInfo tc;
|
|
|
|
|
bool room = MessageHistory::contactByPrefix(_thread_key, tc) && tc.type == ADV_TYPE_ROOM;
|
2026-09-24 23:49:42 +02:00
|
|
|
for (int j = n - 1; j >= 0; j--) {
|
2026-09-25 20:27:32 +02:00
|
|
|
int pos = h.dmHistEntryForContact(_thread_key, j);
|
|
|
|
|
const DmHistEntry& e = h.dmAtPos(pos);
|
2026-09-24 23:49:42 +02:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-25 20:27:32 +02:00
|
|
|
// A room post is filed "Author: text": shown under its author.
|
2026-09-25 19:20:42 +02:00
|
|
|
char who[33] = "";
|
2026-09-25 20:27:32 +02:00
|
|
|
const char* text = e.text;
|
|
|
|
|
if (!e.outgoing && room) text = contactctl::splitRoomPost(e.text, who, sizeof(who));
|
|
|
|
|
else if (!e.outgoing) contactName(_thread_key, who, sizeof(who));
|
|
|
|
|
int loc = e.outgoing ? -1 : noteMsgLocation(text, who);
|
|
|
|
|
bubble(_thread_list, room && !e.outgoing ? who : NULL, text, e.outgoing, e.timestamp, st, col,
|
|
|
|
|
loc, e.outgoing ? -1 : noteMsgMeta(pos, false, false, loc, who)); // nothing to show for our own DM
|
2026-09-24 23:49:42 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
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, "");
|
2026-09-25 00:36:26 +02:00
|
|
|
setKeyboardVisible(false);
|
2026-09-24 23:49:42 +02:00
|
|
|
refreshThread();
|
|
|
|
|
} else {
|
|
|
|
|
showToast("Send failed");
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-25 00:08:15 +02:00
|
|
|
|
|
|
|
|
// ── Settings (first rows; the declarative schema replaces this later) ────────
|
|
|
|
|
|
|
|
|
|
static lv_obj_t* s_kb_main_dd = nullptr;
|
|
|
|
|
static lv_obj_t* s_kb_alt_dd = nullptr;
|
|
|
|
|
|
|
|
|
|
static void onKeyboardAlphabet(lv_event_t* e) {
|
|
|
|
|
(void)e;
|
|
|
|
|
s_ui->setKeyboardAlphabets(lv_dropdown_get_selected(s_kb_main_dd), lv_dropdown_get_selected(s_kb_alt_dd));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// One settings row: label left, dropdown right.
|
|
|
|
|
static lv_obj_t* dropdownRow(lv_obj_t* parent, const char* text, const char* options, int sel) {
|
|
|
|
|
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), theme::ROW_H);
|
|
|
|
|
lv_obj_set_style_radius(row, theme::RADIUS, 0);
|
|
|
|
|
lv_obj_t* l = label(row, text, THEME_FONT_BODY, theme::TEXT);
|
|
|
|
|
lv_obj_align(l, LV_ALIGN_LEFT_MID, theme::PAD, 0);
|
|
|
|
|
lv_obj_t* dd = lv_dropdown_create(row);
|
|
|
|
|
lv_dropdown_set_options_static(dd, options);
|
|
|
|
|
lv_dropdown_set_selected(dd, sel);
|
|
|
|
|
lv_obj_set_width(dd, 130);
|
|
|
|
|
lv_obj_align(dd, LV_ALIGN_RIGHT_MID, -4, 0);
|
|
|
|
|
lv_obj_add_event_cb(dd, onKeyboardAlphabet, LV_EVENT_VALUE_CHANGED, NULL);
|
|
|
|
|
return dd;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 19:20:42 +02:00
|
|
|
// A preference toggle (0/1 byte in NodePrefs), saved on change.
|
|
|
|
|
static void onPrefSwitch(lv_event_t* e) {
|
|
|
|
|
uint8_t* pref = (uint8_t*)lv_event_get_user_data(e);
|
|
|
|
|
*pref = lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0;
|
|
|
|
|
the_mesh.savePrefs();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static void onGpsSwitch(lv_event_t* e) {
|
|
|
|
|
s_ui->setGps(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// pref == nullptr: the caller wires the switch itself.
|
|
|
|
|
static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref) {
|
|
|
|
|
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), theme::ROW_H);
|
|
|
|
|
lv_obj_set_style_radius(row, theme::RADIUS, 0);
|
|
|
|
|
lv_obj_t* l = label(row, text, THEME_FONT_BODY, theme::TEXT);
|
|
|
|
|
lv_obj_align(l, LV_ALIGN_TOP_LEFT, theme::PAD, sub ? 5 : 13);
|
|
|
|
|
if (sub) {
|
|
|
|
|
lv_obj_t* h = label(row, sub, THEME_FONT_SMALL, theme::TEXT_MUTED);
|
|
|
|
|
lv_label_set_long_mode(h, LV_LABEL_LONG_DOT);
|
|
|
|
|
lv_obj_set_size(h, 236, 15); // clear of the switch
|
|
|
|
|
lv_obj_align(h, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5);
|
|
|
|
|
}
|
|
|
|
|
lv_obj_t* sw = lv_switch_create(row);
|
|
|
|
|
lv_obj_set_size(sw, 46, 24);
|
|
|
|
|
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
|
|
|
|
|
if (pref) {
|
|
|
|
|
if (*pref) lv_obj_add_state(sw, LV_STATE_CHECKED);
|
|
|
|
|
lv_obj_add_event_cb(sw, onPrefSwitch, LV_EVENT_VALUE_CHANGED, pref);
|
|
|
|
|
}
|
|
|
|
|
return sw;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Schema-driven settings (ui-core/SettingsSchema.h) ─────────────────────────
|
|
|
|
|
|
|
|
|
|
static void onSchemaSwitch(lv_event_t* e) {
|
|
|
|
|
s_ui->setSchemaValue((int)(uintptr_t)lv_event_get_user_data(e),
|
|
|
|
|
lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED) ? 1 : 0);
|
|
|
|
|
}
|
|
|
|
|
static void onSchemaDropdown(lv_event_t* e) {
|
|
|
|
|
s_ui->setSchemaValue((int)(uintptr_t)lv_event_get_user_data(e),
|
|
|
|
|
(int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e)));
|
|
|
|
|
}
|
|
|
|
|
static void onOpenSchemaPage(lv_event_t* e) { s_ui->showSchemaSettings((int)(uintptr_t)lv_event_get_user_data(e)); }
|
|
|
|
|
static void onPruneContacts(lv_event_t* e) { (void)e; s_ui->pruneContacts(); }
|
|
|
|
|
|
|
|
|
|
void UITask::showSchemaSettings(int page) {
|
|
|
|
|
_screen = SCR_SETTINGS_NAV;
|
|
|
|
|
_settings_page = (uint8_t)(page < settings::PG_COUNT ? page : 0);
|
|
|
|
|
buildSchemaSettings();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Contacts > prune now: first tap shows how many, the second (within 3 s) removes them.
|
|
|
|
|
void UITask::pruneContacts() {
|
|
|
|
|
int n = the_mesh.countStaleContacts();
|
|
|
|
|
if (n == 0) {
|
|
|
|
|
showToast(_prefs && _prefs->contact_expiry_idx == 0 ? "Contact expiry is off" : "No inactive contacts");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!_prune_armed_ms || millis() - _prune_armed_ms > 3000) {
|
|
|
|
|
_prune_armed_ms = millis() | 1;
|
|
|
|
|
if (_prune_lbl) lv_label_set_text_fmt(_prune_lbl, LV_SYMBOL_TRASH " Remove %d contact%s?", n, n == 1 ? "" : "s");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
_prune_armed_ms = 0;
|
|
|
|
|
int removed = the_mesh.pruneStaleContacts();
|
|
|
|
|
char t[40];
|
|
|
|
|
snprintf(t, sizeof(t), "Removed %d contact%s", removed, removed == 1 ? "" : "s");
|
|
|
|
|
showToast(t);
|
|
|
|
|
if (_prune_lbl) lv_label_set_text(_prune_lbl, LV_SYMBOL_TRASH " Remove inactive contacts now");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::applyDisplayPrefs() {
|
|
|
|
|
if (!_prefs) return;
|
|
|
|
|
if (_prefs->display_brightness_pct) lvport::setBacklightPct(_prefs->display_brightness_pct);
|
|
|
|
|
else if (_display) _display->setBrightness(_prefs->display_brightness);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Brightness slider: live while dragging, saved on release.
|
|
|
|
|
static void onBrightnessSlider(lv_event_t* e) {
|
|
|
|
|
lv_obj_t* sl = (lv_obj_t*)lv_event_get_target(e);
|
|
|
|
|
s_ui->setBrightnessPct((uint8_t)lv_slider_get_value(sl), lv_event_get_code(e) == LV_EVENT_RELEASED);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::setBrightnessPct(uint8_t pct, bool save) {
|
|
|
|
|
if (!_prefs) return;
|
|
|
|
|
_prefs->display_brightness_pct = pct;
|
|
|
|
|
_prefs->display_brightness = (uint8_t)((pct + 12) / 25 > 4 ? 4 : (pct + 12) / 25); // nearest level, for anything reading it
|
|
|
|
|
applyDisplayPrefs();
|
|
|
|
|
if (save) the_mesh.savePrefs();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::buildSchemaSettings() {
|
|
|
|
|
lv_obj_t* body = newScreen(settings::pageTitle(_settings_page), true);
|
|
|
|
|
_prune_lbl = nullptr;
|
|
|
|
|
_prune_armed_ms = 0;
|
|
|
|
|
uint8_t sec = 0xFF;
|
|
|
|
|
for (int i = 0; i < settings::COUNT; i++) {
|
|
|
|
|
const settings::Setting& st = settings::ALL[i];
|
|
|
|
|
if (settings::sectionPage(st.section) != _settings_page) continue;
|
|
|
|
|
if (st.section != sec) { sec = st.section; sectionTitle(body, settings::sectionTitle(sec)); }
|
|
|
|
|
uint8_t v = settings::get(*_prefs, st);
|
|
|
|
|
if (st.offset == offsetof(NodePrefs, display_brightness)) { // a slider here instead of five steps
|
|
|
|
|
lv_obj_t* row = lv_obj_create(body);
|
|
|
|
|
styleSurface(row, theme::SURFACE);
|
|
|
|
|
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
|
lv_obj_set_size(row, LV_PCT(100), theme::ROW_H);
|
|
|
|
|
lv_obj_set_style_radius(row, theme::RADIUS, 0);
|
|
|
|
|
lv_obj_align(label(row, st.label, THEME_FONT_BODY, theme::TEXT), LV_ALIGN_LEFT_MID, theme::PAD, 0);
|
|
|
|
|
lv_obj_t* sl = lv_slider_create(row);
|
|
|
|
|
lv_slider_set_range(sl, 5, 100);
|
|
|
|
|
uint8_t pct = _prefs->display_brightness_pct ? _prefs->display_brightness_pct
|
|
|
|
|
: (uint8_t)(_prefs->display_brightness * 25 > 5 ? _prefs->display_brightness * 25 : 5);
|
|
|
|
|
lv_slider_set_value(sl, pct, LV_ANIM_OFF);
|
|
|
|
|
lv_obj_set_size(sl, 170, 10);
|
|
|
|
|
lv_obj_align(sl, LV_ALIGN_RIGHT_MID, -18, 0);
|
|
|
|
|
lv_obj_set_ext_click_area(sl, 14);
|
|
|
|
|
lv_obj_add_event_cb(sl, onBrightnessSlider, LV_EVENT_VALUE_CHANGED, NULL);
|
|
|
|
|
lv_obj_add_event_cb(sl, onBrightnessSlider, LV_EVENT_RELEASED, NULL);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
if (!st.option) {
|
|
|
|
|
lv_obj_t* sw = switchRow(body, st.label, st.hint, nullptr);
|
|
|
|
|
if (v) lv_obj_add_state(sw, LV_STATE_CHECKED);
|
|
|
|
|
lv_obj_add_event_cb(sw, onSchemaSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i);
|
|
|
|
|
continue;
|
|
|
|
|
}
|
|
|
|
|
char opts[160];
|
|
|
|
|
int o = 0;
|
|
|
|
|
for (uint8_t k = 0; k < st.count && o < (int)sizeof(opts) - 16; k++) {
|
|
|
|
|
if (k) opts[o++] = '\n';
|
|
|
|
|
st.option(k, opts + o, sizeof(opts) - o, *_prefs);
|
|
|
|
|
o += strlen(opts + o);
|
|
|
|
|
}
|
|
|
|
|
opts[o] = '\0';
|
|
|
|
|
lv_obj_t* row = lv_obj_create(body);
|
|
|
|
|
styleSurface(row, theme::SURFACE);
|
|
|
|
|
lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE);
|
|
|
|
|
lv_obj_set_size(row, LV_PCT(100), theme::ROW_H);
|
|
|
|
|
lv_obj_set_style_radius(row, theme::RADIUS, 0);
|
|
|
|
|
lv_obj_t* l = label(row, st.label, THEME_FONT_BODY, theme::TEXT);
|
|
|
|
|
lv_obj_align(l, LV_ALIGN_TOP_LEFT, theme::PAD, st.hint ? 5 : 13);
|
|
|
|
|
if (st.hint) {
|
|
|
|
|
lv_obj_t* h = label(row, st.hint, THEME_FONT_SMALL, theme::TEXT_MUTED);
|
|
|
|
|
lv_label_set_long_mode(h, LV_LABEL_LONG_DOT);
|
|
|
|
|
lv_obj_set_size(h, 176, 15); // fixed height: cut, don't wrap onto the label
|
|
|
|
|
lv_obj_align(h, LV_ALIGN_BOTTOM_LEFT, theme::PAD, -5);
|
|
|
|
|
}
|
|
|
|
|
lv_obj_t* dd = lv_dropdown_create(row);
|
|
|
|
|
lv_dropdown_set_options(dd, opts);
|
|
|
|
|
lv_dropdown_set_selected(dd, v);
|
|
|
|
|
lv_obj_set_width(dd, 112);
|
|
|
|
|
lv_obj_align(dd, LV_ALIGN_RIGHT_MID, -4, 0);
|
|
|
|
|
lv_obj_add_event_cb(dd, onSchemaDropdown, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i);
|
|
|
|
|
}
|
|
|
|
|
if (_settings_page == settings::PG_MESSAGES) { // the action that goes with "Contact expiry"
|
|
|
|
|
lv_obj_t* b = lv_button_create(body);
|
|
|
|
|
lv_obj_set_size(b, LV_PCT(100), 38);
|
|
|
|
|
lv_obj_set_style_shadow_width(b, 0, 0);
|
|
|
|
|
lv_obj_set_style_radius(b, theme::RADIUS, 0);
|
|
|
|
|
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE), 0);
|
|
|
|
|
lv_obj_add_event_cb(b, onPruneContacts, LV_EVENT_CLICKED, NULL);
|
|
|
|
|
_prune_lbl = label(b, LV_SYMBOL_TRASH " Remove inactive contacts now", THEME_FONT_BODY, theme::TEXT);
|
|
|
|
|
lv_obj_center(_prune_lbl);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::setSchemaValue(int idx, int v) {
|
|
|
|
|
if (!_prefs || idx < 0 || idx >= settings::COUNT) return;
|
|
|
|
|
const settings::Setting& st = settings::ALL[idx];
|
|
|
|
|
settings::set(*_prefs, st, (uint8_t)v);
|
|
|
|
|
if (st.changed) st.changed(*_core);
|
|
|
|
|
the_mesh.savePrefs();
|
|
|
|
|
if (st.offset == offsetof(NodePrefs, units_imperial)) { // other labels depend on it
|
|
|
|
|
lv_obj_t* body = _body;
|
|
|
|
|
int32_t y = body ? lv_obj_get_scroll_y(body) : 0;
|
|
|
|
|
buildSchemaSettings();
|
|
|
|
|
if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_to_y(_body, y, LV_ANIM_OFF); }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 00:08:15 +02:00
|
|
|
void UITask::showSettings() {
|
|
|
|
|
_screen = SCR_SETTINGS;
|
|
|
|
|
buildSettings();
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-25 11:26:44 +02:00
|
|
|
static void onOpenWifi(lv_event_t* e) { (void)e; s_ui->showWifi(false); }
|
2026-09-25 19:20:42 +02:00
|
|
|
static void onOpenRadio(lv_event_t* e); // RadioScreen.h
|
2026-09-25 11:26:44 +02:00
|
|
|
|
2026-09-25 00:08:15 +02:00
|
|
|
void UITask::buildSettings() {
|
|
|
|
|
lv_obj_t* body = newScreen("Settings", true);
|
2026-09-25 20:51:00 +02:00
|
|
|
sectionTitle(body, "NODE");
|
|
|
|
|
listRow(body, LV_SYMBOL_EDIT " Name", the_mesh.getNodeName(), onNodeName, NULL);
|
2026-09-25 11:26:44 +02:00
|
|
|
sectionTitle(body, "CONNECTIVITY");
|
|
|
|
|
char ssid[33], pass[65];
|
|
|
|
|
bool have = lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass));
|
|
|
|
|
listRow(body, LV_SYMBOL_WIFI " WiFi", have ? ssid : "Not set (for map downloads)", onOpenWifi, NULL);
|
2026-09-25 19:20:42 +02:00
|
|
|
if (_prefs) {
|
|
|
|
|
char sub[48];
|
|
|
|
|
int pi = radioctl::currentPreset(_prefs);
|
|
|
|
|
const char* pn = "Custom"; float f, b; uint8_t sf, cr;
|
|
|
|
|
if (pi >= 0) radioctl::presetAt(_prefs, pi, pn, f, b, sf, cr);
|
|
|
|
|
snprintf(sub, sizeof(sub), "%s - %.3f MHz, %d dBm", pn, _prefs->freq, _prefs->tx_power_dbm);
|
|
|
|
|
listRow(body, UI_SYMBOL_RADIO " Radio", sub, onOpenRadio, NULL);
|
|
|
|
|
}
|
|
|
|
|
if (_prefs) { // the same NodePrefs switches as ui-new's Home GPS toggle / Live share / Locator screens
|
|
|
|
|
sectionTitle(body, "NAVIGATION");
|
|
|
|
|
if (_core->gpsAvailable()) {
|
|
|
|
|
lv_obj_t* sw = switchRow(body, "GPS", "Position for maps, sharing and adverts", nullptr);
|
|
|
|
|
if (_core->gpsEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED);
|
|
|
|
|
lv_obj_add_event_cb(sw, onGpsSwitch, LV_EVENT_VALUE_CHANGED, NULL);
|
|
|
|
|
}
|
|
|
|
|
listRow(body, UI_SYMBOL_COMPASS " Trail, live share, alerts", "Point spacing, auto-pause, sharing, radius",
|
|
|
|
|
onOpenSchemaPage, (void*)(uintptr_t)settings::PG_NAV);
|
|
|
|
|
sectionTitle(body, "DEVICE");
|
|
|
|
|
listRow(body, LV_SYMBOL_EYE_OPEN " Display & power", "Brightness, screen off, battery, GPS, time zone",
|
|
|
|
|
onOpenSchemaPage, (void*)(uintptr_t)settings::PG_DEVICE);
|
|
|
|
|
listRow(body, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resending, contact expiry, sorting",
|
|
|
|
|
onOpenSchemaPage, (void*)(uintptr_t)settings::PG_MESSAGES);
|
|
|
|
|
}
|
2026-09-25 00:08:15 +02:00
|
|
|
sectionTitle(body, "KEYBOARD");
|
|
|
|
|
uint8_t main_a = _prefs ? _prefs->keyboard_main_alphabet : 0;
|
|
|
|
|
uint8_t alt_a = _prefs ? _prefs->keyboard_alt_alphabet : 0;
|
|
|
|
|
if (main_a >= NodePrefs::KB_ALPHABET_COUNT) main_a = 0;
|
|
|
|
|
if (alt_a >= NodePrefs::KB_ALPHABET_COUNT) alt_a = main_a;
|
|
|
|
|
// Order matches NodePrefs::KB_ALPHABET_* (Latin, Cyrillic, Greek).
|
|
|
|
|
s_kb_main_dd = dropdownRow(body, "Main", "Latin\nCyrillic\nGreek", main_a);
|
|
|
|
|
// "None" = no second script (stored as alt == main, as ui-new does).
|
|
|
|
|
s_kb_alt_dd = dropdownRow(body, "Additional", "None\nLatin\nCyrillic\nGreek",
|
|
|
|
|
alt_a == main_a ? 0 : alt_a + 1);
|
|
|
|
|
label(body, "Hold a letter for accents and other variants.", THEME_FONT_SMALL, theme::TEXT_MUTED);
|
2026-09-25 19:20:42 +02:00
|
|
|
|
2026-09-25 20:51:00 +02:00
|
|
|
sectionTitle(body, "SYSTEM");
|
|
|
|
|
listRow(body, LV_SYMBOL_REFRESH " Reboot", NULL, onPowerRow, (void*)(uintptr_t)1);
|
|
|
|
|
listRow(body, LV_SYMBOL_POWER " Power off", NULL, onPowerRow, (void*)(uintptr_t)0);
|
|
|
|
|
|
2026-09-25 19:20:42 +02:00
|
|
|
sectionTitle(body, "ABOUT");
|
|
|
|
|
char about[200], 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", 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));
|
2026-09-25 00:08:15 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) {
|
|
|
|
|
if (!_prefs) return;
|
|
|
|
|
_prefs->keyboard_main_alphabet = (uint8_t)main_idx;
|
|
|
|
|
_prefs->keyboard_alt_alphabet = (uint8_t)(alt_sel == 0 ? main_idx : alt_sel - 1);
|
|
|
|
|
the_mesh.savePrefs();
|
|
|
|
|
}
|
2026-09-25 20:27:32 +02:00
|
|
|
|
|
|
|
|
#include "ConversationScreen.h"
|
2026-09-25 20:51:00 +02:00
|
|
|
#include "DeviceScreen.h"
|