mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-10-09 03:06:39 +00:00
feat(ui-core,ui-lvgl): bot settings table in the Core, L2 bot screen
BotConfig (tabs, rows, the NodePrefs field behind each, reply placeholders) moves into ui-core; ui-new's Tools > Bot renders it. L2: Home > Bot with switches, list pickers, text popups with placeholder buttons, quiet hours. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,16 @@ Confirmed: green dot inside the ring, waypoints much better, download works, set
|
||||
- [ ] Room server login from the phone app / L1 still works while this exists (only admin logins go to the new session)
|
||||
- [ ] L1 (ui-new): Tools > Admin works as before (login, saved password, typed values, confirm reboot); it now also skips the login for the node you just used
|
||||
|
||||
## New: Bot (2026-09-25)
|
||||
- [ ] Home page 2: Clock, Bot
|
||||
- [ ] Bot: tabs Channel / Room / Direct / Other; header shows "N sent" once it has replied
|
||||
- [ ] Switches (Enable, Commands, Actions) change and survive a reboot; Direct > DM allow All / Fav
|
||||
- [ ] Channel / Room: list popup (current one highlighted), picking closes it and shows the name
|
||||
- [ ] Trigger: text popup ("*" = any message, shown as "(any msg)"); Reply: {name} {hops} {loc} {time} buttons insert at the cursor
|
||||
- [ ] Other > Quiet from / to: − / + hour, Done; both the same = "Off"
|
||||
- [ ] With Channel enabled + trigger "!hi" + reply "Hi {name}, {hops}": another node writing "!hi" on that channel gets the reply; `!ping` answered when Commands is on
|
||||
- [ ] L1 (ui-new): Tools > Bot looks and works as before (now from the shared BotConfig table)
|
||||
|
||||
## Basics after the_mesh moved to PSRAM (`MESH_IN_PSRAM`)
|
||||
- [ ] Boots normally, contacts and channels are all there
|
||||
- [ ] Messages arrive and send (channel + DM), delivery ticks work
|
||||
|
||||
@@ -110,7 +110,7 @@ call the same ones, so e.g. "marking a channel read" has one implementation.
|
||||
|
||||
### 5. Declarative settings schema
|
||||
|
||||
`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h`. Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it.
|
||||
`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h`. Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh.
|
||||
|
||||
Replaces the per-item code in `SettingsScreen`:
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#pragma once
|
||||
// The auto-reply bot's settings as a table the frontends render (ui-new's
|
||||
// Tools > Bot, ui-lvgl's Bot screen). The bot itself runs in MyMesh; these
|
||||
// are only its NodePrefs fields, grouped by where it answers: a channel, a
|
||||
// room server, direct messages, plus quiet hours.
|
||||
//
|
||||
// Row kinds: switches (a uint8_t flag), text (trigger / reply, with a
|
||||
// capacity), a target (channel / room: a picker in the frontend), an hour
|
||||
// (quiet from / to; equal hours = quiet hours off).
|
||||
|
||||
namespace botcfg {
|
||||
|
||||
enum Tab : uint8_t { TAB_CHANNEL, TAB_ROOM, TAB_DIRECT, TAB_OTHER, TAB_COUNT };
|
||||
static const char* const TAB_LABELS[TAB_COUNT] = { "Channel", "Room", "Direct", "Other" };
|
||||
|
||||
enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, ACTIONS_DM, TRIGGER_DM, REPLY_DM,
|
||||
ENABLE_CH, CHANNEL, COMMANDS_CH, ACTIONS_CH, TRIGGER_CH, REPLY_CH,
|
||||
ENABLE_ROOM, ROOM, COMMANDS_ROOM, ACTIONS_ROOM, TRIGGER_ROOM, REPLY_ROOM,
|
||||
QUIET_FROM, QUIET_TO };
|
||||
|
||||
// hint: a one-line explanation for frontends with room for it.
|
||||
struct Row { Kind kind; const char* label; const char* hint; };
|
||||
|
||||
static const Row CHANNEL_ROWS[] = {
|
||||
{ ENABLE_CH, "Enable", "Answer on one channel" },
|
||||
{ CHANNEL, "Channel", nullptr },
|
||||
{ COMMANDS_CH, "Commands", "!ping, !help and other ! commands" },
|
||||
{ ACTIONS_CH, "Actions", "!gps, !gpio... (change this device)" },
|
||||
{ TRIGGER_CH, "Trigger", "Text that makes it reply; * = any" },
|
||||
{ REPLY_CH, "Reply", "{name} {hops} {loc} {time} ..." },
|
||||
};
|
||||
static const Row ROOM_ROWS[] = {
|
||||
{ ENABLE_ROOM, "Enable", "Answer in one room server" },
|
||||
{ ROOM, "Room", nullptr },
|
||||
{ COMMANDS_ROOM, "Commands", "!ping, !help and other ! commands" },
|
||||
{ ACTIONS_ROOM, "Actions", "!gps, !gpio... (change this device)" },
|
||||
{ TRIGGER_ROOM, "Trigger", "Text that makes it reply; * = any" },
|
||||
{ REPLY_ROOM, "Reply", "{name} {hops} {loc} {time} ..." },
|
||||
};
|
||||
static const Row DIRECT_ROWS[] = {
|
||||
{ ENABLE_DM, "Enable", "Answer direct messages" },
|
||||
{ DM_SCOPE, "DM allow", "Everyone, or favourites only" },
|
||||
{ COMMANDS_DM, "Commands", "!ping, !help and other ! commands" },
|
||||
{ ACTIONS_DM, "Actions", "!gps, !gpio... (change this device)" },
|
||||
{ TRIGGER_DM, "Trigger", "Text that makes it reply; * = any" },
|
||||
{ REPLY_DM, "Reply", "{name} {hops} {loc} {time} ..." },
|
||||
};
|
||||
static const Row OTHER_ROWS[] = {
|
||||
{ QUIET_FROM, "Quiet from", "No replies between these hours" },
|
||||
{ QUIET_TO, "Quiet to", "Same hour on both = always on" },
|
||||
};
|
||||
|
||||
static int rowCount(int tab) {
|
||||
switch (tab) {
|
||||
case TAB_CHANNEL: return sizeof(CHANNEL_ROWS) / sizeof(Row);
|
||||
case TAB_ROOM: return sizeof(ROOM_ROWS) / sizeof(Row);
|
||||
case TAB_DIRECT: return sizeof(DIRECT_ROWS) / sizeof(Row);
|
||||
default: return sizeof(OTHER_ROWS) / sizeof(Row);
|
||||
}
|
||||
}
|
||||
|
||||
static const Row& row(int tab, int idx) {
|
||||
switch (tab) {
|
||||
case TAB_CHANNEL: return CHANNEL_ROWS[idx];
|
||||
case TAB_ROOM: return ROOM_ROWS[idx];
|
||||
case TAB_DIRECT: return DIRECT_ROWS[idx];
|
||||
default: return OTHER_ROWS[idx];
|
||||
}
|
||||
}
|
||||
|
||||
// On / off rows: the flag; nullptr for other kinds.
|
||||
static uint8_t* flag(NodePrefs* p, Kind k) {
|
||||
switch (k) {
|
||||
case ENABLE_DM: return &p->bot_enabled;
|
||||
case ENABLE_CH: return &p->bot_channel_enabled;
|
||||
case ENABLE_ROOM: return &p->bot_room_enabled;
|
||||
case DM_SCOPE: return &p->bot_dm_scope; // 1 = favourites only
|
||||
case COMMANDS_DM: return &p->bot_commands_enabled;
|
||||
case COMMANDS_CH: return &p->bot_commands_ch;
|
||||
case COMMANDS_ROOM: return &p->bot_commands_room;
|
||||
case ACTIONS_DM: return &p->bot_actions_dm;
|
||||
case ACTIONS_CH: return &p->bot_actions_ch;
|
||||
case ACTIONS_ROOM: return &p->bot_actions_room;
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
// Text rows: the buffer and its size (incl. NUL); nullptr for other kinds.
|
||||
static char* text(NodePrefs* p, Kind k, int& cap) {
|
||||
switch (k) {
|
||||
case TRIGGER_DM: cap = sizeof(p->bot_trigger); return p->bot_trigger;
|
||||
case REPLY_DM: cap = sizeof(p->bot_reply_dm); return p->bot_reply_dm;
|
||||
case TRIGGER_CH: cap = sizeof(p->bot_trigger_ch); return p->bot_trigger_ch;
|
||||
case REPLY_CH: cap = sizeof(p->bot_reply_ch); return p->bot_reply_ch;
|
||||
case TRIGGER_ROOM: cap = sizeof(p->bot_trigger_room); return p->bot_trigger_room;
|
||||
case REPLY_ROOM: cap = sizeof(p->bot_reply_room); return p->bot_reply_room;
|
||||
default: cap = 0; return nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
static bool isTrigger(Kind k) { return k == TRIGGER_DM || k == TRIGGER_CH || k == TRIGGER_ROOM; }
|
||||
static bool isHour(Kind k) { return k == QUIET_FROM || k == QUIET_TO; }
|
||||
|
||||
static uint8_t& hour(NodePrefs* p, Kind k) { return k == QUIET_FROM ? p->bot_quiet_start : p->bot_quiet_end; }
|
||||
static bool quietOff(const NodePrefs* p) { return p->bot_quiet_start == p->bot_quiet_end; }
|
||||
|
||||
// Placeholders a reply can use: the bot fills {name} / {hops} from the
|
||||
// message it answers; {loc} / {time} and the sensor ones come from here.
|
||||
// Most useful first (a frontend short of room shows a prefix).
|
||||
static const char* const REPLY_PLACEHOLDERS[] = { "{name}", "{hops}", "{loc}", "{time}", "{batt}" };
|
||||
static const int REPLY_PLACEHOLDER_COUNT = sizeof(REPLY_PLACEHOLDERS) / sizeof(REPLY_PLACEHOLDERS[0]);
|
||||
|
||||
// Shown value of a text row ("(none)", "(any msg)" for the "*" trigger).
|
||||
static const char* shownText(const char* t, bool trigger) {
|
||||
if (!t[0]) return "(none)";
|
||||
if (trigger && t[0] == '*' && !t[1]) return "(any msg)"; // wildcard / away mode
|
||||
return t;
|
||||
}
|
||||
|
||||
// Target names: false when none is set / it no longer exists.
|
||||
static bool channelName(const NodePrefs* p, char* out, size_t n) {
|
||||
ChannelDetails ch;
|
||||
if (!the_mesh.getChannel(p->bot_channel_idx, ch) || !ch.name[0]) return false;
|
||||
snprintf(out, n, "%s", ch.name);
|
||||
return true;
|
||||
}
|
||||
static bool roomName(const NodePrefs* p, char* out, size_t n) {
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(p->bot_room_prefix, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
if (!c || !c->name[0]) return false;
|
||||
snprintf(out, n, "%s", c->name);
|
||||
return true;
|
||||
}
|
||||
static void setRoom(NodePrefs* p, const uint8_t* pub_key) {
|
||||
memcpy(p->bot_room_prefix, pub_key, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
}
|
||||
|
||||
} // namespace botcfg
|
||||
@@ -0,0 +1,305 @@
|
||||
#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 void onBotPlaceholder(lv_event_t* e) {
|
||||
lv_obj_t* ta = (lv_obj_t*)lv_event_get_user_data(e);
|
||||
lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
|
||||
lv_textarea_add_text(ta, lv_label_get_text(lv_obj_get_child(btn, 0)));
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
static lv_obj_t* segmented(lv_obj_t* parent, const char** map, int sel, int w, int h) {
|
||||
lv_obj_t* m = lv_buttonmatrix_create(parent);
|
||||
lv_buttonmatrix_set_map(m, map);
|
||||
lv_buttonmatrix_set_button_ctrl_all(m, LV_BUTTONMATRIX_CTRL_CHECKABLE);
|
||||
lv_buttonmatrix_set_one_checked(m, true);
|
||||
lv_buttonmatrix_set_button_ctrl(m, sel, LV_BUTTONMATRIX_CTRL_CHECKED);
|
||||
lv_obj_set_size(m, w, h);
|
||||
lv_obj_set_style_pad_all(m, 0, 0);
|
||||
lv_obj_set_style_pad_column(m, 4, 0);
|
||||
lv_obj_set_style_bg_opa(m, LV_OPA_TRANSP, 0);
|
||||
lv_obj_set_style_border_width(m, 0, 0);
|
||||
lv_obj_set_style_bg_color(m, lv_color_hex(theme::SURFACE_2), LV_PART_ITEMS);
|
||||
lv_obj_set_style_bg_color(m, lv_color_hex(theme::ACCENT_DIM), LV_PART_ITEMS | LV_STATE_CHECKED);
|
||||
lv_obj_set_style_text_color(m, lv_color_hex(theme::TEXT), LV_PART_ITEMS);
|
||||
lv_obj_set_style_text_font(m, THEME_FONT_SMALL, LV_PART_ITEMS);
|
||||
lv_obj_set_style_shadow_width(m, 0, LV_PART_ITEMS);
|
||||
lv_obj_set_style_radius(m, 8, LV_PART_ITEMS);
|
||||
return m;
|
||||
}
|
||||
|
||||
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 = lv_obj_create(body);
|
||||
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);
|
||||
lv_obj_set_scrollbar_mode(list, LV_SCROLLBAR_MODE_ACTIVE);
|
||||
|
||||
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 = radioview::settingRow(list, 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) ? 80 : 190); // fixed: LONG_DOT needs a width
|
||||
lv_obj_set_style_text_align(v, LV_TEXT_ALIGN_RIGHT, 0);
|
||||
lv_obj_align(v, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
|
||||
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_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 = lv_textarea_create(panel);
|
||||
lv_textarea_set_one_line(_nav_ta, true);
|
||||
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_set_size(_nav_ta, LV_PCT(100), 34);
|
||||
lv_obj_set_style_border_width(_nav_ta, 1, 0);
|
||||
lv_obj_set_style_pad_ver(_nav_ta, 6, 0);
|
||||
lv_obj_set_style_pad_hor(_nav_ta, 10, 0);
|
||||
lv_obj_set_scrollbar_mode(_nav_ta, LV_SCROLLBAR_MODE_OFF);
|
||||
lv_obj_set_style_border_color(_nav_ta, lv_color_hex(theme::ACCENT), LV_PART_CURSOR | LV_STATE_FOCUSED);
|
||||
lv_obj_set_style_border_width(_nav_ta, 2, LV_PART_CURSOR | LV_STATE_FOCUSED);
|
||||
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);
|
||||
for (int i = 0; i < botcfg::REPLY_PLACEHOLDER_COUNT - 1; i++) { // {batt} and sensors: type them
|
||||
lv_obj_t* b = lv_button_create(chips);
|
||||
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, 13, 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, _nav_ta);
|
||||
lv_obj_center(label(b, botcfg::REPLY_PLACEHOLDERS[i], THEME_FONT_SMALL, theme::TEXT));
|
||||
}
|
||||
}
|
||||
_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);
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "../ui-core/TrackBack.h"
|
||||
#include "../ui-core/RadioControl.h"
|
||||
#include "../ui-core/ChannelControl.h"
|
||||
#include "../ui-core/BotConfig.h"
|
||||
#include "Theme.h"
|
||||
#include "LvglPort.h"
|
||||
#include "../ui-core/KeyboardData.h"
|
||||
@@ -458,6 +459,7 @@ void UITask::back() {
|
||||
case SCR_THREAD: if (_nav_overlay) navClosePopup(); else showChats(); break;
|
||||
case SCR_CONTACTS: showChats(); break;
|
||||
case SCR_CHANNEL_EDIT: showChats(); break;
|
||||
case SCR_BOT: if (_nav_overlay) navClosePopup(); else showHome(); break;
|
||||
case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break;
|
||||
case SCR_SETTINGS: showHome(); break;
|
||||
case SCR_SETTINGS_NAV: showSettings(); break;
|
||||
@@ -494,6 +496,7 @@ static void onOpenNearby(lv_event_t* e) { (void)e; s_ui->showNearby(); }
|
||||
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
|
||||
static void onOpenBot(lv_event_t* e); // BotScreen.h
|
||||
|
||||
// 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.
|
||||
@@ -506,6 +509,7 @@ static const App APPS[] = {
|
||||
{ LV_SYMBOL_SETTINGS, "Settings", onOpenSettings, false },
|
||||
// Page 2: tools
|
||||
{ UI_SYMBOL_CLOCK, "Clock", onOpenClock, false },
|
||||
{ LV_SYMBOL_CHARGE, "Bot", onOpenBot, false },
|
||||
};
|
||||
static const int COUNT = sizeof(APPS) / sizeof(APPS[0]);
|
||||
static const int PER_PAGE = 4;
|
||||
@@ -1263,6 +1267,7 @@ void UITask::nodeAction(uint8_t action) {
|
||||
#include "WifiScreen.h"
|
||||
#include "ChannelScreen.h"
|
||||
#include "AdminScreen.h"
|
||||
#include "BotScreen.h"
|
||||
|
||||
// ── Conversation ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -128,6 +128,14 @@ public:
|
||||
void adminLogin(bool ok);
|
||||
void adminTextDone(bool ok);
|
||||
void adminCancelWait();
|
||||
// Bot settings (BotScreen.h)
|
||||
void showBot();
|
||||
void botTab(int tab);
|
||||
void botRow(int row);
|
||||
void botToggle(int row, bool on);
|
||||
void botPick(int idx);
|
||||
void botHour(uint8_t act);
|
||||
void botTextDone(bool ok);
|
||||
void setGps(bool on);
|
||||
bool ensureGps(); // true with a fix; else turns GPS on / says it's waiting
|
||||
|
||||
@@ -136,7 +144,7 @@ public:
|
||||
void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; }
|
||||
|
||||
private:
|
||||
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN };
|
||||
enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT };
|
||||
|
||||
void buildStatusBar();
|
||||
void refreshStatusBar();
|
||||
@@ -192,6 +200,7 @@ private:
|
||||
void radioCloseFreq();
|
||||
void buildChannelEdit();
|
||||
void buildAdmin();
|
||||
void buildBot();
|
||||
void refreshAdminStatus();
|
||||
void adminLeave();
|
||||
void adminPoll();
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Included by UITask.cpp after KeyboardWidget.h is defined.
|
||||
|
||||
#include "TabBar.h"
|
||||
#include "../ui-core/BotConfig.h"
|
||||
|
||||
class BotScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
@@ -11,59 +12,11 @@ class BotScreen : public UIScreen {
|
||||
// Categories are a circular tab bar in the header (shared geometry with
|
||||
// NearbyScreen's filter tabs and AdminScreen's category tabs — see
|
||||
// TabBar.h). LEFT/RIGHT switches tabs; UP/DOWN moves within the active
|
||||
// tab's rows. Because LEFT/RIGHT is fully owned by tab-switching, every
|
||||
// row's value is edited via Enter only — no more inline LEFT/RIGHT cycling.
|
||||
enum Tab : uint8_t { TAB_CHANNEL, TAB_ROOM, TAB_DIRECT, TAB_OTHER, TAB_COUNT };
|
||||
static const char* TAB_LABELS[TAB_COUNT];
|
||||
|
||||
enum Kind : uint8_t { ENABLE_DM, DM_SCOPE, COMMANDS_DM, ACTIONS_DM, TRIGGER_DM, REPLY_DM,
|
||||
ENABLE_CH, CHANNEL, COMMANDS_CH, ACTIONS_CH, TRIGGER_CH, REPLY_CH,
|
||||
ENABLE_ROOM, ROOM, COMMANDS_ROOM, ACTIONS_ROOM, TRIGGER_ROOM, REPLY_ROOM,
|
||||
QUIET_FROM, QUIET_TO };
|
||||
struct Row { Kind kind; const char* label; };
|
||||
|
||||
static const int ROWS_PER_TAB[TAB_COUNT];
|
||||
|
||||
// Row labels drop the "DM"/"Ch"/"Room" suffix the old flat list needed —
|
||||
// the active tab already gives that context, freeing up value-column width.
|
||||
// Commands lives on each tab (not a shared "Other" toggle) so DM/channel/
|
||||
// room can each independently answer !ping etc. or stay quiet.
|
||||
static Row tabRow(int tab, int idx) {
|
||||
static const Row DIRECT[] = {
|
||||
{ ENABLE_DM, "Enable" },
|
||||
{ DM_SCOPE, "DM allow" },
|
||||
{ COMMANDS_DM, "Commands" },
|
||||
{ ACTIONS_DM, "Actions" },
|
||||
{ TRIGGER_DM, "Trigger" },
|
||||
{ REPLY_DM, "Reply" },
|
||||
};
|
||||
static const Row CHANNEL_ROWS[] = {
|
||||
{ ENABLE_CH, "Enable" },
|
||||
{ CHANNEL, "Channel" },
|
||||
{ COMMANDS_CH, "Commands" },
|
||||
{ ACTIONS_CH, "Actions" },
|
||||
{ TRIGGER_CH, "Trigger" },
|
||||
{ REPLY_CH, "Reply" },
|
||||
};
|
||||
static const Row ROOM_ROWS[] = {
|
||||
{ ENABLE_ROOM, "Enable" },
|
||||
{ ROOM, "Room" },
|
||||
{ COMMANDS_ROOM, "Commands" },
|
||||
{ ACTIONS_ROOM, "Actions" },
|
||||
{ TRIGGER_ROOM, "Trigger" },
|
||||
{ REPLY_ROOM, "Reply" },
|
||||
};
|
||||
static const Row OTHER[] = {
|
||||
{ QUIET_FROM, "Quiet from" },
|
||||
{ QUIET_TO, "Quiet to" },
|
||||
};
|
||||
switch (tab) {
|
||||
case TAB_CHANNEL: return CHANNEL_ROWS[idx];
|
||||
case TAB_ROOM: return ROOM_ROWS[idx];
|
||||
case TAB_DIRECT: return DIRECT[idx];
|
||||
default: return OTHER[idx];
|
||||
}
|
||||
}
|
||||
// tab's rows; every value is edited via Enter. The rows and their NodePrefs
|
||||
// fields are the UI Core's table (ui-core/BotConfig.h, shared with ui-lvgl).
|
||||
typedef botcfg::Kind Kind;
|
||||
typedef botcfg::Row Row;
|
||||
static const Row& tabRow(int tab, int idx) { return botcfg::row(tab, idx); }
|
||||
|
||||
uint8_t _tab; // persists across visits, like NearbyScreen's _filter
|
||||
int _sel; // selected row index within the active tab
|
||||
@@ -109,12 +62,12 @@ class BotScreen : public UIScreen {
|
||||
// Header as a circular tab bar (shared geometry — see TabBar.h). `right_reserve`
|
||||
// keeps the reply counter (drawn after this call) clear of the rightmost tab.
|
||||
void drawTabBar(DisplayDriver& display, int right_reserve) {
|
||||
tabbar::draw(display, TAB_LABELS, TAB_COUNT, _tab, right_reserve);
|
||||
tabbar::draw(display, botcfg::TAB_LABELS, botcfg::TAB_COUNT, _tab, right_reserve);
|
||||
}
|
||||
|
||||
public:
|
||||
BotScreen(UITask* task, NodePrefs* prefs, KeyboardWidget* kb)
|
||||
: _task(task), _prefs(prefs), _tab(TAB_CHANNEL), _kb(kb) {}
|
||||
: _task(task), _prefs(prefs), _tab(botcfg::TAB_CHANNEL), _kb(kb) {}
|
||||
|
||||
void onShow() override {
|
||||
// _tab persists across visits (like NearbyScreen's _filter) — only reset
|
||||
@@ -154,7 +107,7 @@ public:
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
int n = ROWS_PER_TAB[_tab];
|
||||
int n = botcfg::rowCount(_tab);
|
||||
int mq_delay = 0;
|
||||
drawList(display, n, _sel, _scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
Row r = tabRow(_tab, i);
|
||||
@@ -163,106 +116,39 @@ public:
|
||||
display.print(r.label);
|
||||
display.setCursor(val_x, y);
|
||||
|
||||
switch (r.kind) {
|
||||
case ENABLE_DM:
|
||||
display.print(_prefs->bot_enabled ? "ON" : "OFF");
|
||||
break;
|
||||
case ENABLE_CH:
|
||||
display.print(_prefs->bot_channel_enabled ? "ON" : "OFF");
|
||||
break;
|
||||
case ENABLE_ROOM:
|
||||
display.print(_prefs->bot_room_enabled ? "ON" : "OFF");
|
||||
break;
|
||||
case DM_SCOPE:
|
||||
display.print(_prefs->bot_dm_scope ? "Fav" : "All");
|
||||
break;
|
||||
case CHANNEL: {
|
||||
ChannelDetails ch;
|
||||
if (_num_channels == 0)
|
||||
display.print("(none)");
|
||||
else if (the_mesh.getChannel(_prefs->bot_channel_idx, ch) && ch.name[0]) {
|
||||
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, ch.name, sel);
|
||||
if (sel && mqr > 0) mq_delay = mqr;
|
||||
} else
|
||||
display.print("?");
|
||||
break;
|
||||
}
|
||||
case ROOM: {
|
||||
if (_num_rooms == 0) {
|
||||
display.print("(none)");
|
||||
} else {
|
||||
ContactInfo* c = the_mesh.lookupContactByPubKey(_prefs->bot_room_prefix, NodePrefs::FAVOURITE_PREFIX_LEN);
|
||||
if (c && c->name[0]) {
|
||||
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, c->name, sel);
|
||||
if (sel && mqr > 0) mq_delay = mqr;
|
||||
} else
|
||||
display.print("?");
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TRIGGER_DM:
|
||||
case TRIGGER_CH:
|
||||
case TRIGGER_ROOM: {
|
||||
const char* tr = (r.kind == TRIGGER_DM) ? _prefs->bot_trigger
|
||||
: (r.kind == TRIGGER_CH) ? _prefs->bot_trigger_ch
|
||||
: _prefs->bot_trigger_room;
|
||||
const char* shown = !tr[0] ? "(none)"
|
||||
: (tr[0] == '*' && !tr[1]) ? "(any msg)" // wildcard / away mode
|
||||
: tr;
|
||||
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, shown, sel);
|
||||
int cap;
|
||||
const char* txt = botcfg::text(_prefs, r.kind, cap);
|
||||
const uint8_t* fl = botcfg::flag(_prefs, r.kind);
|
||||
char name[32];
|
||||
if (fl) {
|
||||
display.print(r.kind == botcfg::DM_SCOPE ? (*fl ? "Fav" : "All") : (*fl ? "ON" : "OFF"));
|
||||
} else if (txt) {
|
||||
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve,
|
||||
botcfg::shownText(txt, botcfg::isTrigger(r.kind)), sel);
|
||||
if (sel && mqr > 0) mq_delay = mqr;
|
||||
} else if (r.kind == botcfg::CHANNEL || r.kind == botcfg::ROOM) {
|
||||
bool any = r.kind == botcfg::CHANNEL ? _num_channels > 0 : _num_rooms > 0;
|
||||
bool ok = r.kind == botcfg::CHANNEL ? botcfg::channelName(_prefs, name, sizeof(name))
|
||||
: botcfg::roomName(_prefs, name, sizeof(name));
|
||||
if (!any) display.print("(none)");
|
||||
else if (!ok) display.print("?");
|
||||
else {
|
||||
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, name, sel);
|
||||
if (sel && mqr > 0) mq_delay = mqr;
|
||||
break;
|
||||
}
|
||||
case REPLY_DM:
|
||||
case REPLY_CH:
|
||||
case REPLY_ROOM: {
|
||||
const char* rp = (r.kind == REPLY_DM) ? _prefs->bot_reply_dm
|
||||
: (r.kind == REPLY_CH) ? _prefs->bot_reply_ch
|
||||
: _prefs->bot_reply_room;
|
||||
int mqr = display.drawTextEllipsized(val_x, y, display.width() - val_x - 1 - reserve, rp[0] ? rp : "(none)", sel);
|
||||
if (sel && mqr > 0) mq_delay = mqr;
|
||||
break;
|
||||
} else if (botcfg::isHour(r.kind)) {
|
||||
char hb[10];
|
||||
if (botcfg::quietOff(_prefs)) strcpy(hb, "OFF");
|
||||
else snprintf(hb, sizeof(hb), "%02d:00", botcfg::hour(_prefs, r.kind));
|
||||
// Bracket the value while the stepper sub-mode is open on this row,
|
||||
// as a visual cue that UP/DOWN now steps it instead of moving rows.
|
||||
if (_stepper_row == i) {
|
||||
char bracketed[14];
|
||||
snprintf(bracketed, sizeof(bracketed), "[%s]", hb);
|
||||
display.print(bracketed);
|
||||
} else {
|
||||
display.print(hb);
|
||||
}
|
||||
case COMMANDS_DM:
|
||||
display.print(_prefs->bot_commands_enabled ? "ON" : "OFF");
|
||||
break;
|
||||
case COMMANDS_CH:
|
||||
display.print(_prefs->bot_commands_ch ? "ON" : "OFF");
|
||||
break;
|
||||
case COMMANDS_ROOM:
|
||||
display.print(_prefs->bot_commands_room ? "ON" : "OFF");
|
||||
break;
|
||||
case ACTIONS_DM:
|
||||
display.print(_prefs->bot_actions_dm ? "ON" : "OFF");
|
||||
break;
|
||||
case ACTIONS_CH:
|
||||
display.print(_prefs->bot_actions_ch ? "ON" : "OFF");
|
||||
break;
|
||||
case ACTIONS_ROOM:
|
||||
display.print(_prefs->bot_actions_room ? "ON" : "OFF");
|
||||
break;
|
||||
case QUIET_FROM:
|
||||
case QUIET_TO: {
|
||||
bool off = (_prefs->bot_quiet_start == _prefs->bot_quiet_end);
|
||||
char hb[10];
|
||||
if (off) {
|
||||
strcpy(hb, "OFF");
|
||||
} else {
|
||||
uint8_t h = (r.kind == QUIET_FROM) ? _prefs->bot_quiet_start : _prefs->bot_quiet_end;
|
||||
snprintf(hb, sizeof(hb), "%02d:00", h);
|
||||
}
|
||||
// Bracket the value while the stepper sub-mode is open on this row,
|
||||
// as a visual cue that UP/DOWN now steps it instead of moving rows.
|
||||
if (_stepper_row == i) {
|
||||
char bracketed[14];
|
||||
snprintf(bracketed, sizeof(bracketed), "[%s]", hb);
|
||||
display.print(bracketed);
|
||||
} else {
|
||||
display.print(hb);
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
});
|
||||
@@ -279,8 +165,8 @@ public:
|
||||
auto res = _kb->handleInput(c);
|
||||
if (res == KeyboardWidget::DONE) {
|
||||
Kind k = tabRow(_tab, _kb_row).kind;
|
||||
char* dst = fieldBuf(k);
|
||||
int cap = fieldCap(k);
|
||||
int cap = 0;
|
||||
char* dst = botcfg::text(_prefs, k, cap);
|
||||
if (dst) { strncpy(dst, _kb->buf, cap - 1); dst[cap - 1] = '\0'; }
|
||||
_dirty = true;
|
||||
_kb_row = -1;
|
||||
@@ -291,8 +177,7 @@ public:
|
||||
}
|
||||
|
||||
if (_stepper_row >= 0) {
|
||||
Kind k = tabRow(_tab, _stepper_row).kind;
|
||||
uint8_t& h = (k == QUIET_FROM) ? _prefs->bot_quiet_start : _prefs->bot_quiet_end;
|
||||
uint8_t& h = botcfg::hour(_prefs, tabRow(_tab, _stepper_row).kind);
|
||||
if (up) { h = (h + 1) % 24; _dirty = true; return true; }
|
||||
if (down) { h = (h + 23) % 24; _dirty = true; return true; }
|
||||
if (enter || cancel) { _stepper_row = -1; return true; }
|
||||
@@ -304,10 +189,10 @@ public:
|
||||
_task->gotoToolsScreen();
|
||||
return true;
|
||||
}
|
||||
if (keyIsPrev(c)) { _tab = (_tab + TAB_COUNT - 1) % TAB_COUNT; _sel = _scroll = 0; return true; }
|
||||
if (keyIsNext(c)) { _tab = (_tab + 1) % TAB_COUNT; _sel = _scroll = 0; return true; }
|
||||
if (keyIsPrev(c)) { _tab = (_tab + botcfg::TAB_COUNT - 1) % botcfg::TAB_COUNT; _sel = _scroll = 0; return true; }
|
||||
if (keyIsNext(c)) { _tab = (_tab + 1) % botcfg::TAB_COUNT; _sel = _scroll = 0; return true; }
|
||||
|
||||
int n = ROWS_PER_TAB[_tab];
|
||||
int n = botcfg::rowCount(_tab);
|
||||
// drawList() reclamps _scroll from _sel every render.
|
||||
if (up) { _sel = (_sel > 0) ? _sel - 1 : n - 1; return true; }
|
||||
if (down) { _sel = (_sel < n - 1) ? _sel + 1 : 0; return true; }
|
||||
@@ -315,83 +200,41 @@ public:
|
||||
if (!enter) return false;
|
||||
Kind k = tabRow(_tab, _sel).kind;
|
||||
|
||||
if (uint8_t* fl = botcfg::flag(_prefs, k)) { *fl ^= 1; _dirty = true; return true; }
|
||||
int cap = 0;
|
||||
if (char* txt = botcfg::text(_prefs, k, cap)) {
|
||||
_kb_row = _sel;
|
||||
_kb->begin(txt, cap - 1);
|
||||
if (botcfg::isTrigger(k)) {
|
||||
_kb->clearPlaceholders(); // trigger is literal — placeholders never match
|
||||
} else {
|
||||
kbAddSensorPlaceholders(*_kb, &sensors);
|
||||
// Only meaningful in a bot reply (there's an actual triggering
|
||||
// sender/hop-count to fill them with) — not offered on the general
|
||||
// compose keyboard's own placeholder list.
|
||||
_kb->addPlaceholder("{name}");
|
||||
_kb->addPlaceholder("{hops}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
switch (k) {
|
||||
case ENABLE_DM: _prefs->bot_enabled ^= 1; _dirty = true; return true;
|
||||
case ENABLE_CH: _prefs->bot_channel_enabled ^= 1; _dirty = true; return true;
|
||||
case ENABLE_ROOM: _prefs->bot_room_enabled ^= 1; _dirty = true; return true;
|
||||
case DM_SCOPE: _prefs->bot_dm_scope ^= 1; _dirty = true; return true;
|
||||
case COMMANDS_DM: _prefs->bot_commands_enabled ^= 1; _dirty = true; return true;
|
||||
case COMMANDS_CH: _prefs->bot_commands_ch ^= 1; _dirty = true; return true;
|
||||
case COMMANDS_ROOM: _prefs->bot_commands_room ^= 1; _dirty = true; return true;
|
||||
case ACTIONS_DM: _prefs->bot_actions_dm ^= 1; _dirty = true; return true;
|
||||
case ACTIONS_CH: _prefs->bot_actions_ch ^= 1; _dirty = true; return true;
|
||||
case ACTIONS_ROOM: _prefs->bot_actions_room ^= 1; _dirty = true; return true;
|
||||
case CHANNEL:
|
||||
case botcfg::CHANNEL:
|
||||
// Full browsable channel picker (same experience as Live Share's "To"
|
||||
// row); which channel is *active* is now the separate Enable row.
|
||||
// row); which channel is *active* is the separate Enable row.
|
||||
if (_num_channels == 0) return false;
|
||||
_task->savePrefsIfDirty(_dirty);
|
||||
_task->pickBotChannelTarget();
|
||||
return true;
|
||||
case ROOM:
|
||||
case botcfg::ROOM:
|
||||
if (_num_rooms == 0) return false;
|
||||
_task->savePrefsIfDirty(_dirty);
|
||||
_task->pickBotRoomTarget();
|
||||
return true;
|
||||
case QUIET_FROM:
|
||||
case QUIET_TO:
|
||||
case botcfg::QUIET_FROM:
|
||||
case botcfg::QUIET_TO:
|
||||
_stepper_row = _sel;
|
||||
return true;
|
||||
case TRIGGER_DM:
|
||||
case TRIGGER_CH:
|
||||
case TRIGGER_ROOM:
|
||||
case REPLY_DM:
|
||||
case REPLY_CH:
|
||||
case REPLY_ROOM: {
|
||||
_kb_row = _sel;
|
||||
_kb->begin(fieldBuf(k), fieldCap(k) - 1);
|
||||
bool is_trigger = (k == TRIGGER_DM || k == TRIGGER_CH || k == TRIGGER_ROOM);
|
||||
if (is_trigger) {
|
||||
_kb->clearPlaceholders(); // trigger is literal — placeholders never match
|
||||
} else {
|
||||
kbAddSensorPlaceholders(*_kb, &sensors);
|
||||
// Only meaningful in a bot reply (there's an actual triggering
|
||||
// sender/hop-count to fill them with) — not offered on the general
|
||||
// compose keyboard's own placeholder list.
|
||||
_kb->addPlaceholder("{name}");
|
||||
_kb->addPlaceholder("{hops}");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
// Maps a keyboard-editable row kind to its backing string + capacity.
|
||||
char* fieldBuf(Kind k) {
|
||||
switch (k) {
|
||||
case TRIGGER_DM: return _prefs->bot_trigger;
|
||||
case REPLY_DM: return _prefs->bot_reply_dm;
|
||||
case TRIGGER_CH: return _prefs->bot_trigger_ch;
|
||||
case REPLY_CH: return _prefs->bot_reply_ch;
|
||||
case TRIGGER_ROOM: return _prefs->bot_trigger_room;
|
||||
case REPLY_ROOM: return _prefs->bot_reply_room;
|
||||
default: return nullptr;
|
||||
}
|
||||
}
|
||||
int fieldCap(Kind k) {
|
||||
switch (k) {
|
||||
case TRIGGER_DM: return sizeof(_prefs->bot_trigger);
|
||||
case REPLY_DM: return sizeof(_prefs->bot_reply_dm);
|
||||
case TRIGGER_CH: return sizeof(_prefs->bot_trigger_ch);
|
||||
case REPLY_CH: return sizeof(_prefs->bot_reply_ch);
|
||||
case TRIGGER_ROOM: return sizeof(_prefs->bot_trigger_room);
|
||||
case REPLY_ROOM: return sizeof(_prefs->bot_reply_room);
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const char* BotScreen::TAB_LABELS[BotScreen::TAB_COUNT] = { "Channel", "Room", "Direct", "Other" };
|
||||
const int BotScreen::ROWS_PER_TAB[BotScreen::TAB_COUNT] = { 6, 6, 6, 2 };
|
||||
|
||||
Reference in New Issue
Block a user