Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/BotScreen.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

282 lines
12 KiB
C++

#pragma once
// Auto-reply bot settings (Home > Bot) -- ui-new's Tools > Bot. The rows and
// their NodePrefs fields are ui-core/BotConfig.h; the bot runs in MyMesh.
// Tabs Channel / Room / Direct / Other; switches inline, trigger / reply in a
// popup with the keyboard (reply: placeholder buttons above it), channel /
// room from a list popup, quiet hours with - / +.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after AdminScreen.h.
namespace botview {
static uint8_t s_tab = botcfg::TAB_CHANNEL;
static int s_row = -1; // row a popup is editing
static lv_obj_t* s_hour_lbl = nullptr;
static uint8_t s_rooms[32][NodePrefs::FAVOURITE_PREFIX_LEN];
enum : uint8_t { B_MINUS, B_PLUS, B_DONE };
} // namespace botview
static void onBotTab(lv_event_t* e) {
s_ui->botTab((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onBotRow(lv_event_t* e) { s_ui->botRow((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onBotSwitch(lv_event_t* e) {
s_ui->botToggle((int)(uintptr_t)lv_event_get_user_data(e),
lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED));
}
static void onBotScope(lv_event_t* e) {
s_ui->botToggle((int)(uintptr_t)lv_event_get_user_data(e),
lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)) == 1);
}
static void onBotPick(lv_event_t* e) { s_ui->botPick((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onBotHour(lv_event_t* e) { s_ui->botHour((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onBotTextKb(lv_event_t* e) { s_ui->botTextDone(lv_event_get_code(e) == LV_EVENT_READY); }
static lv_obj_t* s_chip_ta = nullptr; // the reply field the chips type into
static void onBotPlaceholder(lv_event_t* e) {
lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
if (s_chip_ta) lv_textarea_add_text(s_chip_ta, lv_label_get_text(lv_obj_get_child(btn, 0)));
}
// A placeholder button on `parent` (the reply editor's chips row).
static void botChip(const char* ph, void* parent) {
lv_obj_t* b = lv_button_create((lv_obj_t*)parent);
lv_obj_set_height(b, 26);
lv_obj_set_style_pad_hor(b, 7, 0);
lv_obj_set_style_pad_ver(b, 0, 0);
lv_obj_set_style_radius(b, LV_RADIUS_CIRCLE, 0);
lv_obj_set_style_shadow_width(b, 0, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), 0);
lv_obj_add_event_cb(b, onBotPlaceholder, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(b, ph, THEME_FONT_SMALL, theme::TEXT));
}
static void onOpenBot(lv_event_t* e) { (void)e; s_ui->showBot(); }
static lv_obj_t* botHourButton(lv_obj_t* parent, const char* text, uint8_t act, 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_shadow_width(b, 0, 0);
lv_obj_set_style_radius(b, theme::RADIUS, 0);
lv_obj_set_style_bg_color(b, lv_color_hex(accent ? theme::ACCENT_DIM : theme::SURFACE), 0);
lv_obj_add_event_cb(b, onBotHour, LV_EVENT_CLICKED, (void*)(uintptr_t)act);
lv_obj_center(label(b, text, act == botview::B_DONE ? THEME_FONT_BODY : THEME_FONT_LARGE, theme::TEXT));
return b;
}
void UITask::showBot() {
_screen = SCR_BOT;
buildBot();
}
void UITask::buildBot() {
using namespace botview;
lv_obj_t* body = newScreen("Bot", true);
lv_obj_set_style_pad_row(body, 4, 0);
NodePrefs* p = _prefs;
if (!p) return;
uint16_t sent = the_mesh.botReplyCount();
if (sent > 0 && _header) { // replies since boot
lv_obj_t* c = label(_header, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_text_fmt(c, "%u sent", (unsigned)sent);
lv_obj_align(c, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
}
static const char* TABS[botcfg::TAB_COUNT + 1];
for (int i = 0; i < botcfg::TAB_COUNT; i++) TABS[i] = botcfg::TAB_LABELS[i];
TABS[botcfg::TAB_COUNT] = "";
lv_obj_t* tabs = segmented(body, TABS, s_tab, lv_pct(100), 34);
lv_obj_set_style_bg_color(tabs, lv_color_hex(theme::SURFACE), LV_PART_ITEMS);
lv_obj_add_event_cb(tabs, onBotTab, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_t* list = scrollList(body);
lv_obj_t* g = group(list, nullptr);
for (int i = 0; i < botcfg::rowCount(s_tab); i++) {
const botcfg::Row& r = botcfg::row(s_tab, i);
uint8_t* fl = botcfg::flag(p, r.kind);
lv_obj_t* row = settingRow(g, r.label, fl || botcfg::isHour(r.kind) ? r.hint : nullptr);
if (fl && r.kind == botcfg::DM_SCOPE) {
static const char* SCOPE[] = { "All", "Fav", "" };
lv_obj_t* seg = segmented(row, SCOPE, *fl ? 1 : 0, 110, 32);
lv_obj_align(seg, LV_ALIGN_RIGHT_MID, -4, 0);
lv_obj_add_event_cb(seg, onBotScope, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i);
continue;
}
if (fl) {
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 (*fl) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onBotSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)i);
continue;
}
// Value on the right; tap the row to change it.
char val[48];
int cap;
if (const char* t = botcfg::text(p, r.kind, cap)) snprintf(val, sizeof(val), "%s", botcfg::shownText(t, botcfg::isTrigger(r.kind)));
else if (r.kind == botcfg::CHANNEL) { if (!botcfg::channelName(p, val, sizeof(val))) snprintf(val, sizeof(val), "(pick one)"); }
else if (r.kind == botcfg::ROOM) { if (!botcfg::roomName(p, val, sizeof(val))) snprintf(val, sizeof(val), "(pick one)"); }
else if (botcfg::quietOff(p)) snprintf(val, sizeof(val), "Off");
else snprintf(val, sizeof(val), "%02d:00", botcfg::hour(p, r.kind));
lv_obj_t* v = label(row, val, THEME_FONT_BODY, theme::ACCENT);
lv_label_set_long_mode(v, LV_LABEL_LONG_DOT);
lv_obj_set_width(v, botcfg::isHour(r.kind) ? 60 : 150); // fixed: LONG_DOT needs a width
lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0);
label(row, LV_SYMBOL_RIGHT, THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_set_style_bg_color(row, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED);
lv_obj_set_style_bg_opa(row, LV_OPA_COVER, LV_STATE_PRESSED);
lv_obj_add_event_cb(row, onBotRow, LV_EVENT_CLICKED, (void*)(uintptr_t)i);
}
}
void UITask::botTab(int tab) {
if (tab < 0 || tab >= botcfg::TAB_COUNT) return;
botview::s_tab = (uint8_t)tab;
buildBot();
}
void UITask::botToggle(int row, bool on) {
if (!_prefs || row < 0 || row >= botcfg::rowCount(botview::s_tab)) return;
uint8_t* fl = botcfg::flag(_prefs, botcfg::row(botview::s_tab, row).kind);
if (!fl) return;
*fl = on ? 1 : 0;
the_mesh.savePrefs();
}
// Tap on a value row: its editor.
void UITask::botRow(int row) {
using namespace botview;
NodePrefs* p = _prefs;
if (!p || row < 0 || row >= botcfg::rowCount(s_tab)) return;
s_row = row;
const botcfg::Row& r = botcfg::row(s_tab, row);
int cap;
if (char* t = botcfg::text(p, r.kind, cap)) {
bool reply = !botcfg::isTrigger(r.kind);
lv_obj_t* panel = navPopupPanel(r.label, false);
lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard
_nav_ta = textField(panel);
lv_textarea_set_max_length(_nav_ta, cap - 1);
lv_textarea_set_text(_nav_ta, t);
lv_textarea_set_placeholder_text(_nav_ta, botcfg::isTrigger(r.kind) ? "e.g. !hi, or * for any message" : "Reply text");
lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED);
if (reply) {
// Placeholders the bot fills in, as buttons in place of the title (no
// room for another row above the keyboard; the field says "Reply").
lv_obj_t* hdr = lv_obj_get_child(panel, 0);
lv_obj_add_flag(lv_obj_get_child(hdr, 0), LV_OBJ_FLAG_HIDDEN);
lv_obj_t* chips = lv_obj_create(hdr);
lv_obj_remove_style_all(chips);
lv_obj_set_size(chips, 240, 28);
lv_obj_align(chips, LV_ALIGN_LEFT_MID, 0, 0);
lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_gap(chips, 4, 0);
lv_obj_set_scroll_dir(chips, LV_DIR_HOR); // swipe for the rest
lv_obj_set_scrollbar_mode(chips, LV_SCROLLBAR_MODE_OFF);
s_chip_ta = _nav_ta;
for (int i = 0; i < botcfg::REPLY_PLACEHOLDER_COUNT; i++) botChip(botcfg::REPLY_PLACEHOLDERS[i], chips);
msgtext::sensorPlaceholders(&sensors, [](const char* ph, void* c) {
if (strcmp(ph, "{batt}") != 0) botChip(ph, c); // already there
}, chips);
}
_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, onBotTextKb, LV_EVENT_READY, NULL);
lv_obj_add_event_cb(_nav_kb, onBotTextKb, LV_EVENT_CANCEL, NULL);
return;
}
if (r.kind == botcfg::CHANNEL || r.kind == botcfg::ROOM) {
lv_obj_t* panel = navPopupPanel(r.kind == botcfg::CHANNEL ? "Answer on channel" : "Answer in room", 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);
int n = 0;
if (r.kind == botcfg::CHANNEL) {
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
ChannelDetails ch;
if (!the_mesh.getChannel(i, ch) || !ch.name[0]) continue;
lv_obj_t* rw = listRow(list, ch.name, NULL, onBotPick, (void*)(uintptr_t)i);
if (i == p->bot_channel_idx) lv_obj_set_style_bg_color(rw, lv_color_hex(theme::ACCENT_DIM), 0);
n++;
}
} else {
int total = the_mesh.getNumContacts();
for (int i = 0; i < total && n < (int)(sizeof(s_rooms) / sizeof(s_rooms[0])); i++) {
ContactInfo c;
if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c) || c.type != ADV_TYPE_ROOM) continue;
memcpy(s_rooms[n], c.id.pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
lv_obj_t* rw = listRow(list, c.name, NULL, onBotPick, (void*)(uintptr_t)n);
if (memcmp(c.id.pub_key, p->bot_room_prefix, NodePrefs::FAVOURITE_PREFIX_LEN) == 0)
lv_obj_set_style_bg_color(rw, lv_color_hex(theme::ACCENT_DIM), 0);
n++;
}
}
if (n == 0) label(list, r.kind == botcfg::CHANNEL ? "No channels." : "No room servers in your contacts.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
return;
}
if (botcfg::isHour(r.kind)) {
lv_obj_t* panel = navPopupPanel(r.label, false);
label(panel, "Same hour for both = no quiet hours.", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* rw = adminRowBox(panel);
botHourButton(rw, LV_SYMBOL_MINUS, B_MINUS, false);
s_hour_lbl = label(rw, "", THEME_FONT_LARGE, theme::TEXT);
lv_obj_set_width(s_hour_lbl, 80);
lv_obj_set_style_text_align(s_hour_lbl, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text_fmt(s_hour_lbl, "%02d:00", botcfg::hour(p, r.kind));
botHourButton(rw, LV_SYMBOL_PLUS, B_PLUS, false);
rw = adminRowBox(panel);
botHourButton(rw, LV_SYMBOL_OK " Done", B_DONE, true);
}
}
void UITask::botTextDone(bool ok) {
using namespace botview;
if (ok && _nav_ta && _prefs && s_row >= 0 && s_row < botcfg::rowCount(s_tab)) {
int cap;
char* t = botcfg::text(_prefs, botcfg::row(s_tab, s_row).kind, cap);
if (t) {
snprintf(t, cap, "%s", lv_textarea_get_text(_nav_ta));
the_mesh.savePrefs();
}
}
navClosePopup();
if (ok) buildBot();
}
void UITask::botPick(int idx) {
using namespace botview;
if (!_prefs || s_row < 0) return;
botcfg::Kind k = botcfg::row(s_tab, s_row).kind;
if (k == botcfg::CHANNEL) _prefs->bot_channel_idx = (uint8_t)idx;
else if (k == botcfg::ROOM && idx >= 0 && idx < (int)(sizeof(s_rooms) / sizeof(s_rooms[0]))) botcfg::setRoom(_prefs, s_rooms[idx]);
the_mesh.savePrefs();
navClosePopup();
buildBot();
}
void UITask::botHour(uint8_t act) {
using namespace botview;
if (!_prefs || s_row < 0) return;
botcfg::Kind k = botcfg::row(s_tab, s_row).kind;
uint8_t& h = botcfg::hour(_prefs, k);
if (act == B_DONE) {
the_mesh.savePrefs();
navClosePopup();
buildBot();
return;
}
h = (uint8_t)((h + (act == B_PLUS ? 1 : 23)) % 24);
if (s_hour_lbl) lv_label_set_text_fmt(s_hour_lbl, "%02d:00", h);
}