Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/DeviceScreen.h
T
JakubandClaude Opus 5.5 a40a87d02c feat(ui-lvgl): settings kit, choice picker, reorganised Settings
group() / settingRow / listRow / actionRow / choiceRow / groupNote: one card
per section, hairlines between rows, hints that wrap. Dropdowns replaced by
a centred picker list. Settings regrouped (Device, Connections, Map & data,
System); Display & power split into Display, Power and Time on L2 only
(the shared schema is unchanged), Keyboard and About get their own pages.
Radio and Repeater share one LoRa parameter block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 01:17:48 +02:00

618 lines
28 KiB
C++

#pragma once
// Device-level pieces from ui-new's Settings > System and Home:
// - node name (Settings > Node), reboot / power off (Settings > System);
// - lock screen (Settings > Display & power > Lock screen, NodePrefs::auto_lock):
// once the screen turns off, waking shows a clock card and nothing reacts
// until "slide to unlock" -- against touches in a pocket; with a screen PIN
// (NVS, lvport::loadPin) the card always comes up, also after a reboot, and
// asks for the PIN on a keypad instead of the slider;
// - favourites dial (Home > Favourites, NodePrefs::favourite_contacts): six
// slots holding a contact, room or channel; tap opens it, hold changes /
// removes it; "Pin" in a conversation's / channel's options puts it on one.
// Slot storage is ui-core/Favourites.h, names contactctl::favName, shared
// with ui-new's dial.
//
// Single-TU fragment: included at the end of ui-lvgl/UITask.cpp.
namespace devview {
static lv_obj_t* s_lock = nullptr; // lock overlay (lv_layer_top)
static lv_obj_t* s_lock_clock = nullptr;
static lv_obj_t* s_lock_date = nullptr;
static lv_obj_t* s_lock_unread = nullptr;
static lv_obj_t* s_lock_slider = nullptr;
static lv_obj_t* s_pin_dots = nullptr; // lock card / setup popup: one dot per digit typed
static lv_obj_t* s_pin_msg = nullptr; // "Wrong PIN" / "Try again in 30 s" / setup step
static const uint8_t PIN_MIN = 4, PIN_MAX = 8;
static const uint8_t PIN_TRIES = 5; // misses before a pause
static const uint32_t PIN_PAUSE_MS = 30000;
static const char* const PIN_MAP[] = { "1", "2", "3", "\n", "4", "5", "6", "\n", "7", "8", "9", "\n",
LV_SYMBOL_BACKSPACE, "0", LV_SYMBOL_OK, "" };
static int s_fav_slot = -1; // slot the hold / pick popup is about
static const int PICK_MAX = 128;
static uint8_t (*s_pick_keys)[PUB_KEY_SIZE] = psramBuf<uint8_t[PUB_KEY_SIZE]>(PICK_MAX);
enum : int { PICK_CONTACT = 100 }; // pick codes: channel index, or PICK_CONTACT + row
// "Pin" from an options popup: what gets pinned.
static bool s_pin_channel = false;
static uint8_t s_pin_ch = 0;
static uint8_t s_pin_key[PUB_KEY_SIZE];
enum : uint8_t { F_CHANGE, F_REMOVE };
// Text field whose limit is in UTF-8 bytes (user data: the byte cap).
static void onByteCapInsert(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);
size_t cap = (size_t)(uintptr_t)lv_event_get_user_data(e);
if (ins && strlen(lv_textarea_get_text(ta)) + strlen(ins) > cap) lv_textarea_set_insert_replace(ta, "");
}
} // namespace devview
static void onNodeName(lv_event_t* e) { (void)e; s_ui->nodeNamePopup(); }
static void onNodeNameKb(lv_event_t* e) { s_ui->nodeNameDone(lv_event_get_code(e) == LV_EVENT_READY); }
static void onPowerRow(lv_event_t* e) { s_ui->powerPopup((uintptr_t)lv_event_get_user_data(e) != 0); }
static void onPowerGo(lv_event_t* e) { s_ui->shutdown((uintptr_t)lv_event_get_user_data(e) != 0); }
static void onFavTap(lv_event_t* e) { s_ui->favTap((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onFavHold(lv_event_t* e) {
lv_indev_wait_release(lv_indev_active()); // the hold isn't also a tap that opens it
s_ui->favHold((int)(uintptr_t)lv_event_get_user_data(e));
}
static void onFavAction(lv_event_t* e) { s_ui->favAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onFavPick(lv_event_t* e) { s_ui->favPick((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onPinSlot(lv_event_t* e) { s_ui->pinTo((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onPinPad(lv_event_t* e) {
lv_obj_t* m = (lv_obj_t*)lv_event_get_target(e);
const char* k = lv_buttonmatrix_get_button_text(m, lv_buttonmatrix_get_selected_button(m));
if (!k) return;
if (lv_event_get_user_data(e)) s_ui->pinSetupKey(k); else s_ui->pinKey(k);
}
static void onPinSetup(lv_event_t* e) { (void)e; s_ui->pinSetupPopup(); }
static void onPinRemove(lv_event_t* e) { (void)e; s_ui->pinRemove(); }
namespace devview {
// The digit keypad (1-9, backspace, 0, OK) under a row of dots.
static lv_obj_t* pinPad(lv_obj_t* parent, bool setup, int w, int h) {
s_pin_dots = label(parent, "", THEME_FONT_LARGE, theme::TEXT);
lv_obj_set_style_text_letter_space(s_pin_dots, 6, 0);
lv_obj_set_height(s_pin_dots, 22);
s_pin_msg = label(parent, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* m = lv_buttonmatrix_create(parent);
lv_buttonmatrix_set_map(m, PIN_MAP);
lv_obj_set_size(m, w, h);
lv_obj_set_style_bg_opa(m, LV_OPA_TRANSP, 0);
lv_obj_set_style_border_width(m, 0, 0);
lv_obj_set_style_pad_all(m, 0, 0);
lv_obj_set_style_pad_gap(m, 6, 0);
lv_obj_set_style_bg_color(m, lv_color_hex(theme::SURFACE), LV_PART_ITEMS);
lv_obj_set_style_bg_color(m, lv_color_hex(theme::SURFACE_2), LV_PART_ITEMS | LV_STATE_PRESSED);
lv_obj_set_style_text_font(m, THEME_FONT_LARGE, LV_PART_ITEMS);
lv_obj_set_style_text_color(m, lv_color_hex(theme::TEXT), LV_PART_ITEMS);
lv_obj_set_style_radius(m, theme::RADIUS, LV_PART_ITEMS);
lv_obj_set_style_shadow_width(m, 0, LV_PART_ITEMS);
lv_obj_add_event_cb(m, onPinPad, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)(setup ? 1 : 0));
return m;
}
static void showDots(const char* entry) {
if (!s_pin_dots) return;
char d[PIN_MAX * 3 + 1] = "";
for (size_t i = 0; entry[i] && i < PIN_MAX; i++) strcat(d, "\xE2\x80\xA2"); // U+2022 bullet
lv_label_set_text(s_pin_dots, d);
}
// Digit / backspace into `entry`; true when OK was pressed.
static bool pinEdit(char* entry, const char* key) {
size_t n = strlen(entry);
if (!strcmp(key, LV_SYMBOL_BACKSPACE)) { if (n) entry[n - 1] = '\0'; }
else if (!strcmp(key, LV_SYMBOL_OK)) return true;
else if (key[0] >= '0' && key[0] <= '9' && !key[1] && n < PIN_MAX) { entry[n] = key[0]; entry[n + 1] = '\0'; }
showDots(entry);
return false;
}
} // namespace devview
// ── Node name, reboot, power off ──────────────────────────────────────────────
void UITask::nodeNamePopup() {
if (!_prefs) return;
lv_obj_t* panel = navPopupPanel("Node name", false);
lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard
_nav_ta = wifiField(panel, "Shown to others in adverts", false);
lv_obj_remove_event_cb(_nav_ta, onWifiField);
lv_textarea_set_text(_nav_ta, the_mesh.getNodeName());
lv_obj_add_event_cb(_nav_ta, devview::onByteCapInsert, LV_EVENT_INSERT,
(void*)(uintptr_t)(sizeof(_prefs->node_name) - 1));
lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); // draws the cursor
_nav_kb = kb::create(_nav_overlay, _prefs);
lv_obj_set_size(_nav_kb, LV_PCT(100), 124);
lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_keyboard_set_textarea(_nav_kb, _nav_ta);
lv_obj_add_event_cb(_nav_kb, onNodeNameKb, LV_EVENT_READY, NULL);
lv_obj_add_event_cb(_nav_kb, onNodeNameKb, LV_EVENT_CANCEL, NULL);
}
void UITask::nodeNameDone(bool ok) {
if (ok && _nav_ta && _prefs) {
const char* t = lv_textarea_get_text(_nav_ta);
if (!t[0]) { showToast("Name can't be empty"); return; }
snprintf(_prefs->node_name, sizeof(_prefs->node_name), "%s", t);
the_mesh.savePrefs(); // getNodeName() and the self-advert read node_name live
showToast("Name saved - others see it with your next advert", 3000);
}
navClosePopup();
if (ok && _screen == SCR_SETTINGS) buildSettings();
}
void UITask::powerPopup(bool restart) {
lv_obj_t* panel = navPopupPanel(restart ? "Reboot?" : "Power off?", false);
lv_obj_t* t = label(panel, restart ? "Restarts the device. Messages and settings are kept."
: "Turns the device off. Messages and settings are kept; no messages arrive while it is off.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
lv_obj_t* b = lv_button_create(panel);
lv_obj_set_size(b, LV_PCT(100), 40);
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::FAIL), 0);
lv_obj_add_event_cb(b, onPowerGo, LV_EVENT_CLICKED, (void*)(uintptr_t)(restart ? 1 : 0));
lv_obj_center(label(b, restart ? LV_SYMBOL_REFRESH " Reboot" : LV_SYMBOL_POWER " Power off", THEME_FONT_BODY, theme::TEXT));
}
// ── Lock screen ───────────────────────────────────────────────────────────────
// From sleep() when the lock is on. Built while the screen is dark, so waking
// shows it straight away.
void UITask::lockScreen() {
using namespace devview;
if (s_lock) return;
navClosePopup();
setKeyboardVisible(false);
s_lock = lv_obj_create(lv_layer_top());
lv_obj_remove_style_all(s_lock);
lv_obj_set_size(s_lock, LV_PCT(100), lv_display_get_vertical_resolution(NULL) - theme::STATUS_H);
lv_obj_set_pos(s_lock, 0, theme::STATUS_H); // the status bar stays visible
lv_obj_set_style_bg_color(s_lock, lv_color_hex(theme::BG), 0);
lv_obj_set_style_bg_opa(s_lock, LV_OPA_COVER, 0);
lv_obj_add_flag(s_lock, LV_OBJ_FLAG_CLICKABLE); // swallows every touch but the slider's
lv_obj_remove_flag(s_lock, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_flex_flow(s_lock, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(s_lock, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_top(s_lock, 18, 0);
lv_obj_set_style_pad_row(s_lock, 4, 0);
if (_pin[0]) { // compact card: the time and unread on one line, the keypad below
lv_obj_set_style_pad_top(s_lock, 4, 0);
lv_obj_set_style_pad_row(s_lock, 2, 0);
lv_obj_t* top = lv_obj_create(s_lock);
lv_obj_remove_style_all(top);
lv_obj_set_size(top, LV_SIZE_CONTENT, LV_SIZE_CONTENT);
lv_obj_set_flex_flow(top, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(top, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_column(top, 14, 0);
s_lock_clock = label(top, "--:--", THEME_FONT_LARGE, theme::TEXT);
s_lock_unread = label(top, "", THEME_FONT_BODY, theme::ACCENT);
s_lock_date = nullptr;
_pin_entry[0] = '\0';
pinPad(s_lock, false, 228, 124);
showDots(_pin_entry);
if (_toast) lv_obj_move_foreground(_toast);
refreshLock();
return;
}
s_lock_clock = label(s_lock, "--:--", THEME_FONT_CLOCK, theme::TEXT);
s_lock_date = label(s_lock, "", THEME_FONT_BODY, theme::TEXT_MUTED);
s_lock_unread = label(s_lock, "", THEME_FONT_BODY, theme::ACCENT);
lv_obj_set_style_pad_top(s_lock_unread, 8, 0);
lv_obj_t* track = lv_obj_create(s_lock); // grows to push the slider to the bottom
lv_obj_remove_style_all(track);
lv_obj_set_width(track, 1);
lv_obj_set_flex_grow(track, 1);
s_lock_slider = lv_slider_create(s_lock);
lv_obj_set_size(s_lock_slider, 250, 44);
lv_obj_set_style_margin_bottom(s_lock_slider, 44, 0); // clear of the toast below
lv_slider_set_range(s_lock_slider, 0, 100);
lv_obj_set_style_anim_duration(s_lock_slider, 0, 0); // no lag behind the finger
lv_obj_set_style_bg_color(s_lock_slider, lv_color_hex(theme::SURFACE), LV_PART_MAIN);
lv_obj_set_style_bg_opa(s_lock_slider, LV_OPA_COVER, LV_PART_MAIN);
lv_obj_set_style_radius(s_lock_slider, LV_RADIUS_CIRCLE, LV_PART_MAIN);
lv_obj_set_style_bg_opa(s_lock_slider, LV_OPA_TRANSP, LV_PART_INDICATOR);
lv_obj_set_style_bg_color(s_lock_slider, lv_color_hex(theme::ACCENT), LV_PART_KNOB);
lv_obj_set_style_pad_all(s_lock_slider, 2, LV_PART_KNOB);
lv_obj_set_style_shadow_width(s_lock_slider, 0, LV_PART_KNOB);
lv_obj_remove_flag(s_lock_slider, LV_OBJ_FLAG_CLICKABLE); // lockPoll() drives it
lv_obj_t* hint = label(s_lock_slider, "slide to unlock " LV_SYMBOL_RIGHT, THEME_FONT_BODY, theme::TEXT_MUTED);
lv_obj_align(hint, LV_ALIGN_CENTER, 14, 0);
if (_toast) lv_obj_move_foreground(_toast); // new-message toasts still show
refreshLock();
}
void UITask::unlockScreen() {
using namespace devview;
if (!s_lock) return;
lv_indev_wait_release(lv_indev_active()); // the slide isn't also a tap underneath
// The lock's content goes at once and its background fades away as the
// screen underneath drifts up into place -- the same cheap cover fade as a
// screen change (Anim.h), which deletes the overlay at the end.
lv_obj_t* fading = s_lock;
for (uint32_t i = 0; i < lv_obj_get_child_count(fading); i++)
lv_obj_add_flag(lv_obj_get_child(fading, i), LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_flag(fading, LV_OBJ_FLAG_CLICKABLE);
if (_asleep) lv_obj_delete_async(fading);
else {
anim::run(fading, anim::setBgOpa, LV_OPA_COVER, LV_OPA_TRANSP, anim::UNLOCK_MS, anim::coverDone);
if (_body) anim::run(_body, anim::setTy, 12, 0, anim::UNLOCK_MS);
}
s_lock = s_lock_clock = s_lock_date = s_lock_unread = s_lock_slider = s_pin_dots = s_pin_msg = nullptr;
_pin_entry[0] = '\0';
if (_screen == SCR_HOME) refreshHome();
else if (_screen == SCR_CHATS) buildChats(); // counts moved on while locked
}
bool UITask::locked() const { return devview::s_lock != nullptr; }
// From loop() while locked: the knob follows the finger and unlocks when let
// go at the end (a knob let go earlier springs back). Tracked from the touch
// itself, like the Home page swipe -- LVGL stops feeding a pressed widget
// once the finger has moved past its scroll threshold.
void UITask::lockPoll() {
using namespace devview;
static bool s_dragging = false, s_was_down = false;
lv_indev_t* in = lv_indev_get_next(NULL);
if (!s_lock_slider || !in) { s_dragging = s_was_down = false; return; }
bool down = lv_indev_get_state(in) == LV_INDEV_STATE_PRESSED;
lv_point_t p;
lv_indev_get_point(in, &p);
lv_area_t a;
lv_obj_get_coords(s_lock_slider, &a);
int32_t knob = lv_area_get_height(&a); // round knob, as tall as the track
if (down && !s_was_down) { // a drag starts on the knob only
s_dragging = p.y >= a.y1 - 10 && p.y <= a.y2 + 10 && p.x <= a.x1 + knob + 16;
if (s_dragging) lv_anim_delete(s_lock_slider, anim::setSlider);
}
s_was_down = down;
if (!down) {
if (!s_dragging) return;
s_dragging = false;
int32_t v = lv_slider_get_value(s_lock_slider);
if (v >= 100) unlockScreen();
else if (v > 0) anim::run(s_lock_slider, anim::setSlider, v, 0, anim::SPRING_MS);
return;
}
if (!s_dragging) return;
int32_t span = lv_area_get_width(&a) - knob;
int32_t v = span > 0 ? (p.x - a.x1 - knob / 2) * 100 / span : 0;
v = v < 0 ? 0 : v >= 92 ? 100 : v; // snaps to the end: let go to unlock
lv_slider_set_value(s_lock_slider, v, LV_ANIM_OFF);
}
void UITask::refreshLock() {
using namespace devview;
if (!s_lock) return;
struct tm ti;
if (localTime(_prefs, ti)) {
char clk[12], date[48];
fmtClock(clk, sizeof(clk), ti, _prefs, false, true);
fmtDate(date, sizeof(date), ti, _prefs);
lv_label_set_text(s_lock_clock, clk);
if (s_lock_date) lv_label_set_text(s_lock_date, date);
}
int unread = unreadTotal();
if (!s_lock_date) { // PIN card: the count next to the clock, the keypad's line
if (unread > 0) lv_label_set_text_fmt(s_lock_unread, LV_SYMBOL_ENVELOPE " %d", unread);
else lv_label_set_text(s_lock_unread, "");
if (!s_pin_msg) return;
int32_t left = (int32_t)(_pin_block_until - millis());
if (_pin_block_until && left > 0) lv_label_set_text_fmt(s_pin_msg, "Too many tries - wait %ld s", (long)((left + 999) / 1000));
else if (_pin_block_until) { _pin_block_until = 0; lv_label_set_text(s_pin_msg, "Enter PIN"); }
else if (!lv_label_get_text(s_pin_msg)[0]) lv_label_set_text(s_pin_msg, "Enter PIN");
return;
}
if (unread > 0) lv_label_set_text_fmt(s_lock_unread, LV_SYMBOL_ENVELOPE " %d new message%s", unread, unread == 1 ? "" : "s");
else lv_label_set_text(s_lock_unread, "");
}
// ── Screen PIN ────────────────────────────────────────────────────────────────
// Lock card keypad: the PIN unlocks as soon as it's complete (OK checks a
// shorter entry); five misses pause entry for 30 s.
void UITask::pinKey(const char* key) {
using namespace devview;
if (!s_lock || !_pin[0]) return;
if (_pin_block_until && (int32_t)(_pin_block_until - millis()) > 0) return;
bool ok = pinEdit(_pin_entry, key);
size_t n = strlen(_pin_entry);
if (!ok && n < strlen(_pin)) {
if (n && s_pin_msg) lv_label_set_text(s_pin_msg, "Enter PIN");
return;
}
if (!n) return;
if (!strcmp(_pin_entry, _pin)) { _pin_fails = 0; unlockScreen(); return; }
_pin_entry[0] = '\0';
showDots(_pin_entry);
if (++_pin_fails >= PIN_TRIES) {
_pin_fails = 0;
_pin_block_until = (millis() + PIN_PAUSE_MS) | 1;
refreshLock();
} else if (s_pin_msg) {
lv_label_set_text_fmt(s_pin_msg, "Wrong PIN - %d tr%s left", PIN_TRIES - _pin_fails, PIN_TRIES - _pin_fails == 1 ? "y" : "ies");
}
}
// Settings > Display & power > Screen PIN: a new PIN typed twice; with one
// set, "Remove" in the header too.
void UITask::pinSetupPopup() {
using namespace devview;
_pin_entry[0] = _pin_new[0] = '\0';
lv_obj_t* panel = navPopupPanel(_pin[0] ? "Change PIN" : "Set screen PIN", true);
lv_obj_set_flex_align(panel, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_row(panel, 2, 0);
if (_pin[0]) { // next to the close button
lv_obj_t* l;
headerButton(lv_obj_get_child(panel, 0), LV_SYMBOL_TRASH " Remove", onPinRemove, 44, &l);
lv_obj_set_style_text_color(l, lv_color_hex(theme::FAIL), 0);
}
pinPad(panel, true, 228, 116);
lv_label_set_text(s_pin_msg, "New PIN, 4 to 8 digits, then " LV_SYMBOL_OK);
}
void UITask::pinSetupKey(const char* key) {
using namespace devview;
if (!s_pin_msg || !pinEdit(_pin_entry, key)) return;
size_t n = strlen(_pin_entry);
if (n < PIN_MIN) { lv_label_set_text(s_pin_msg, "At least 4 digits"); return; }
if (!_pin_new[0]) { // first entry: ask again
strcpy(_pin_new, _pin_entry);
_pin_entry[0] = '\0';
showDots(_pin_entry);
lv_label_set_text(s_pin_msg, "Once more to confirm, then " LV_SYMBOL_OK);
return;
}
if (strcmp(_pin_new, _pin_entry) != 0) {
_pin_new[0] = _pin_entry[0] = '\0';
showDots(_pin_entry);
lv_label_set_text(s_pin_msg, "Didn't match - new PIN again");
return;
}
strcpy(_pin, _pin_new);
lvport::savePin(_pin);
_pin_new[0] = _pin_entry[0] = '\0';
navClosePopup();
showToast("PIN set - asked each time the screen wakes", 3000);
pinRowRefresh();
}
void UITask::pinRemove() {
_pin[0] = '\0';
lvport::savePin("");
navClosePopup();
showToast("PIN removed");
pinRowRefresh();
}
// Display & power rebuilt for the Screen PIN row, still at its bottom.
void UITask::pinRowRefresh() {
if (_screen != SCR_SETTINGS_NAV) return;
buildSchemaSettings();
if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_by(_body, 0, -lv_obj_get_scroll_bottom(_body), LV_ANIM_OFF); }
}
// ── Accent colour ─────────────────────────────────────────────────────────────
static void onAccent(lv_event_t* e) { s_ui->setAccent((int)(uintptr_t)lv_event_get_user_data(e)); }
// A row of colour swatches; the current one ringed.
void UITask::accentRow(lv_obj_t* body) { // a row of a group: the label over the swatches
lv_obj_t* card = groupLine(body, false);
lv_obj_set_flex_flow(card, LV_FLEX_FLOW_COLUMN);
lv_obj_set_flex_align(card, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START);
lv_obj_set_style_pad_row(card, 8, 0);
lv_obj_set_style_pad_bottom(card, 10, 0);
label(card, "Accent colour", THEME_FONT_BODY, theme::TEXT);
lv_obj_t* row = lv_obj_create(card);
lv_obj_remove_style_all(row);
lv_obj_set_size(row, LV_PCT(100), 36);
lv_obj_set_flex_flow(row, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(row, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
for (int i = 0; i < theme::ACCENT_COUNT; i++) {
lv_obj_t* sw = lv_button_create(row);
lv_obj_set_size(sw, 32, 32);
lv_obj_set_style_radius(sw, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_bg_color(sw, lv_color_hex(theme::ACCENTS[i].col), 0);
bool cur = i == theme::s_accent;
lv_obj_set_style_border_width(sw, cur ? 3 : 0, 0);
lv_obj_set_style_border_color(sw, lv_color_hex(theme::TEXT), 0);
lv_obj_add_event_cb(sw, onAccent, LV_EVENT_CLICKED, (void*)(uintptr_t)i);
if (cur) lv_obj_center(label(sw, LV_SYMBOL_OK, THEME_FONT_SMALL, theme::BG));
}
}
// Recolours everything: the theme, the status bar and toast, then this page.
void UITask::setAccent(int idx) {
if (idx == theme::s_accent) return;
theme::setAccent(idx);
lvport::saveAccent(theme::s_accent);
theme::install(lv_display_get_default());
if (_toast) lv_obj_set_style_border_color(_toast, lv_color_hex(theme::ACCENT), 0);
_status_sig[0] = '\0'; // icons in the accent rebuilt
refreshStatusBar();
pinRowRefresh(); // the page, kept at its bottom where this row is
}
// ── Favourites card (Home, left of the main page) ─────────────────────────────
// The six slots, three to a row, into `grid` (a wrapping row).
void UITask::favGrid(lv_obj_t* grid) {
int w = (lv_display_get_horizontal_resolution(NULL) - 2 * theme::PAD - 2 * theme::GAP) / 3;
for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) {
char name[33];
ContactInfo c;
bool used = contactctl::favName(_prefs, s, name, sizeof(name), &c);
bool chan = favslots::kind(_prefs, s) == NodePrefs::FAV_KIND_CHANNEL;
lv_obj_t* b = lv_button_create(grid);
lv_obj_set_size(b, w, 76);
lv_obj_set_style_pad_all(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(used ? theme::SURFACE : theme::BG), 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
if (!used) {
lv_obj_set_style_border_color(b, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_set_style_border_width(b, 1, 0);
}
lv_obj_add_event_cb(b, onFavTap, LV_EVENT_CLICKED, (void*)(uintptr_t)s);
lv_obj_add_event_cb(b, onFavHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)s);
const char* icon = !used ? LV_SYMBOL_PLUS : chan ? "#" : c.type == ADV_TYPE_ROOM ? LV_SYMBOL_HOME : UI_SYMBOL_USERS;
lv_obj_align(label(b, icon, THEME_FONT_LARGE, used ? theme::ACCENT : theme::TEXT_MUTED), LV_ALIGN_TOP_MID, 0, 10);
lv_obj_t* n = label(b, used ? name : "Add", THEME_FONT_SMALL, used ? theme::TEXT : theme::TEXT_MUTED);
lv_label_set_long_mode(n, LV_LABEL_LONG_DOT);
lv_obj_set_style_text_align(n, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_set_width(n, w - 10);
lv_obj_align(n, LV_ALIGN_BOTTOM_MID, 0, -8);
if (used) {
int unread = chan ? _core->history.chUnread(_prefs->favourite_contacts[s][0])
: (c.type == ADV_TYPE_CHAT ? _core->dmUnread(c.id.pub_key) : 0);
badge(b, unread, false);
}
}
lv_obj_t* hint = label(grid, "Tap to open. Hold to change or remove.", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_set_width(hint, LV_PCT(100));
lv_obj_set_style_text_align(hint, LV_TEXT_ALIGN_CENTER, 0);
}
// A slot changed: the card redrawn if it's up.
void UITask::favRefresh() {
if (_screen == SCR_HOME && home::s_page == home::FAVS) setHomePage(home::FAVS);
}
void UITask::favTap(int slot) {
char name[33];
ContactInfo c;
if (!contactctl::favName(_prefs, slot, name, sizeof(name), &c)) { favPickPopup(slot); return; }
if (favslots::kind(_prefs, slot) == NodePrefs::FAV_KIND_CHANNEL) openChannel(_prefs->favourite_contacts[slot][0]);
else if (c.type == ADV_TYPE_ROOM) openRoom(c.id.pub_key);
else openDM(c.id.pub_key);
}
void UITask::favHold(int slot) {
char name[33];
if (!contactctl::favName(_prefs, slot, name, sizeof(name))) { favPickPopup(slot); return; }
devview::s_fav_slot = slot;
lv_obj_t* panel = navPopupPanel(name, false);
lv_obj_t* acts = convview::actionRow(panel);
convview::actionButton(acts, LV_SYMBOL_EDIT " Change", onFavAction, devview::F_CHANGE, false);
convview::actionButton(acts, LV_SYMBOL_TRASH " Remove", onFavAction, devview::F_REMOVE, false);
}
void UITask::favAction(uint8_t act) {
int slot = devview::s_fav_slot;
if (act == devview::F_CHANGE) { favPickPopup(slot); return; }
favslots::clear(_prefs, slot);
the_mesh.savePrefs();
navClosePopup();
favRefresh();
}
// What to put in a slot: channels, then contacts and rooms (favourites first).
void UITask::favPickPopup(int slot) {
using namespace devview;
s_fav_slot = slot;
char title[24];
snprintf(title, sizeof(title), "Slot %d", slot + 1);
lv_obj_t* panel = navPopupPanel(title, true);
lv_obj_t* list = lv_obj_create(panel);
styleSurface(list, theme::BG);
lv_obj_set_width(list, LV_PCT(100));
lv_obj_set_flex_grow(list, 1);
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
lv_obj_set_style_pad_row(list, theme::GAP, 0);
sectionTitle(list, "CHANNELS");
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || !ch.name[0]) continue;
listRow(list, ch.name, NULL, onFavPick, (void*)(uintptr_t)i);
}
sectionTitle(list, "CONTACTS AND ROOMS");
int rows = 0, total = the_mesh.getNumContacts();
const int MAX_ROWS = PICK_MAX;
for (int pass = 0; pass < 2; pass++) {
for (int i = 0; i < total && rows < MAX_ROWS; i++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue;
if (c.type != ADV_TYPE_CHAT && c.type != ADV_TYPE_ROOM) continue;
if (contactctl::favourite(c) != (pass == 0)) continue;
memcpy(s_pick_keys[rows], c.id.pub_key, PUB_KEY_SIZE);
char t[48];
snprintf(t, sizeof(t), "%s%s%s", contactctl::favourite(c) ? UI_SYMBOL_STAR " " : "", c.name,
c.type == ADV_TYPE_ROOM ? " (room)" : "");
listRow(list, t, NULL, onFavPick, (void*)(uintptr_t)(PICK_CONTACT + rows));
rows++;
}
}
}
void UITask::favPick(int code) {
using namespace devview;
if (code >= PICK_CONTACT) favslots::pinContact(_prefs, s_fav_slot, s_pick_keys[code - PICK_CONTACT]);
else favslots::pinChannel(_prefs, s_fav_slot, (uint8_t)code);
the_mesh.savePrefs();
navClosePopup();
favRefresh();
}
// "Pin" in an options popup: six slot buttons with who is in them now.
void UITask::pinPopup(bool channel, uint8_t ch_idx, const uint8_t* pub_key) {
using namespace devview;
s_pin_channel = channel;
s_pin_ch = ch_idx;
if (pub_key) memcpy(s_pin_key, pub_key, PUB_KEY_SIZE);
lv_obj_t* panel = navPopupPanel("Pin to favourites", false);
lv_obj_t* grid = lv_obj_create(panel);
styleSurface(grid, theme::BG);
lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE);
lv_obj_set_size(grid, LV_PCT(100), LV_SIZE_CONTENT);
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
lv_obj_set_style_pad_column(grid, theme::GAP, 0);
lv_obj_set_style_pad_row(grid, theme::GAP, 0);
int here = channel ? favslots::findChannel(_prefs, ch_idx) : favslots::findContact(_prefs, pub_key);
for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) {
char name[33], t[48];
bool used = contactctl::favName(_prefs, s, name, sizeof(name));
snprintf(t, sizeof(t), "%d %s", s + 1, used ? name : "empty");
lv_obj_t* b = lv_button_create(grid);
lv_obj_set_size(b, 90, 36);
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(s == here ? theme::ACCENT_DIM : theme::SURFACE), 0);
lv_obj_add_event_cb(b, onPinSlot, LV_EVENT_CLICKED, (void*)(uintptr_t)s);
lv_obj_t* l = label(b, t, THEME_FONT_SMALL, used ? theme::TEXT : theme::TEXT_MUTED);
lv_label_set_long_mode(l, LV_LABEL_LONG_DOT);
lv_obj_set_width(l, 82);
lv_obj_center(l);
}
label(panel, here >= 0 ? "Tap its slot again to unpin." : "A taken slot is replaced.", THEME_FONT_SMALL, theme::TEXT_MUTED);
}
void UITask::pinTo(int slot) {
using namespace devview;
int here = s_pin_channel ? favslots::findChannel(_prefs, s_pin_ch) : favslots::findContact(_prefs, s_pin_key);
char msg[32];
if (here == slot) {
favslots::clear(_prefs, slot);
snprintf(msg, sizeof(msg), "Unpinned");
} else {
if (s_pin_channel) favslots::pinChannel(_prefs, slot, s_pin_ch);
else favslots::pinContact(_prefs, slot, s_pin_key);
snprintf(msg, sizeof(msg), "Pinned to slot %d", slot + 1);
}
the_mesh.savePrefs();
navClosePopup();
showToast(msg, 1500);
}