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

335 lines
14 KiB
C++

#pragma once
// Channel management -- ui-new's Messages > Channels options and Add / Edit
// form. Options popup (hold a channel row, or the ⚙ in an open channel):
// notifications, scope, favourite, edit, delete. Add channel: Public (the
// well-known channel), Hashtag ("#topic", secret derived from the name) or
// Private (name + passphrase or 32-hex key). Edit: new name, secret kept unless
// a new one is typed. The logic is ui-core/ChannelControl.h, shared with ui-new.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after RadioScreen.h.
namespace chanview {
enum : uint8_t { A_FAV, A_EDIT, A_DELETE, A_READ, A_PIN };
enum : uint8_t { T_PUBLIC, T_HASHTAG, T_PRIVATE };
enum : uint8_t { C_NOTIF, C_SCOPE, C_MELODY };
static int s_idx = -1; // channel the popup / form is about (-1: adding)
static uint8_t s_type = T_HASHTAG;
static bool s_hex = false;
static uint32_t s_del_armed_ms = 0;
static lv_obj_t* s_del_lbl = nullptr;
static lv_obj_t* s_fav_btn = nullptr;
// Add / Edit form
static lv_obj_t* s_types = nullptr;
static lv_obj_t* s_name = nullptr;
static lv_obj_t* s_secret = nullptr;
static lv_obj_t* s_hex_sw = nullptr;
static lv_obj_t* s_hint = nullptr;
static lv_obj_t* s_status = nullptr;
static lv_obj_t* s_kb = nullptr;
static char s_scope_opts[256];
static void clearForm() {
s_types = s_name = s_secret = s_hex_sw = s_hint = s_status = s_kb = nullptr;
}
} // namespace chanview
static void onChanAction(lv_event_t* e) { s_ui->channelAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onChanDropdown(lv_event_t* e) {
s_ui->channelSet((uint8_t)(uintptr_t)lv_event_get_user_data(e),
choiceSelected((lv_obj_t*)lv_event_get_target(e)));
}
static void onChanRowHold(lv_event_t* e) {
lv_indev_wait_release(lv_indev_active()); // the hold isn't also a tap that opens the channel
s_ui->channelMenu((int)(uintptr_t)lv_event_get_user_data(e));
}
static void onChanThreadMenu(lv_event_t* e) { (void)e; s_ui->channelMenu(-1); }
static void onChanAdd(lv_event_t* e) { (void)e; s_ui->showChannelEdit(-1); }
static void onChanType(lv_event_t* e) {
s_ui->channelEditType((int)lv_buttonmatrix_get_selected_button((lv_obj_t*)lv_event_get_target(e)));
}
static void onChanHex(lv_event_t* e) {
s_ui->channelEditHex(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED));
}
static void onChanField(lv_event_t* e) { s_ui->channelEditField((lv_obj_t*)lv_event_get_target(e)); }
static void onChanSave(lv_event_t* e) { (void)e; s_ui->channelEditSave(); }
static void onChanKb(lv_event_t* e) {
if (lv_event_get_code(e) == LV_EVENT_READY) s_ui->channelEditSave();
else s_ui->channelEditKbHide();
}
// ── Options popup ─────────────────────────────────────────────────────────────
// idx -1: the channel open in the thread.
void UITask::channelMenu(int idx) {
using namespace chanview;
if (idx < 0) idx = _thread_channel;
ChannelDetails ch;
if (!chanctl::exists(idx) || !the_mesh.getChannel(idx, ch)) return;
s_idx = idx;
s_del_armed_ms = 0;
s_del_lbl = s_fav_btn = nullptr;
char title[40];
snprintf(title, sizeof(title), "%s", ch.name); // as named: "#" marks a hashtag channel
lv_obj_t* panel = navPopupPanel(title, false);
lv_obj_t* g = group(panel, nullptr);
choiceRow(g, "Alerts", nullptr, "Default\nMuted\nAlways", chanctl::notif(_prefs, idx), onChanDropdown,
(void*)(uintptr_t)C_NOTIF);
choiceRow(g, "Sound", nullptr, "Default\nMelody 1\nMelody 2", chanctl::melody(_prefs, idx), onChanDropdown,
(void*)(uintptr_t)C_MELODY);
const ScopeList& sl = the_mesh.scopeList();
if (sl.count > 0) { // only once regions are set up (Settings in the app)
int o = 0;
for (uint8_t i = 0; i <= sl.count && o < (int)sizeof(s_scope_opts) - 24; i++)
o += snprintf(s_scope_opts + o, sizeof(s_scope_opts) - o, "%s%s", i ? "\n" : "", sl.name(i));
choiceRow(g, "Scope", "Region it's sent in", s_scope_opts, chanctl::scope(_prefs, idx), onChanDropdown,
(void*)(uintptr_t)C_SCOPE);
}
lv_obj_t* acts = lv_obj_create(panel);
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 unread = _core->history.chUnread(idx) > 0;
struct { const char* text; uint8_t act; } btns[] = {
{ UI_SYMBOL_STAR " Fav", A_FAV },
{ LV_SYMBOL_OK " Read", A_READ },
{ UI_SYMBOL_PIN, A_PIN },
{ LV_SYMBOL_EDIT " Edit", A_EDIT },
{ LV_SYMBOL_TRASH, A_DELETE },
};
for (auto& b : btns) {
if (b.act == A_READ && !unread) continue;
lv_obj_t* bt = lv_button_create(acts);
lv_obj_set_height(bt, 40);
lv_obj_set_flex_grow(bt, 1);
lv_obj_set_style_pad_hor(bt, 4, 0);
lv_obj_set_style_radius(bt, theme::RADIUS, 0);
lv_obj_set_style_shadow_width(bt, 0, 0);
lv_obj_set_style_bg_color(bt, lv_color_hex(theme::SURFACE), 0);
lv_obj_add_event_cb(bt, onChanAction, LV_EVENT_CLICKED, (void*)(uintptr_t)b.act);
lv_obj_t* l = label(bt, b.text, THEME_FONT_SMALL, theme::TEXT);
lv_obj_center(l);
if (b.act == A_DELETE) s_del_lbl = l;
if (b.act == A_FAV) s_fav_btn = bt;
}
if (s_fav_btn && chanctl::favourite(_prefs, idx))
lv_obj_set_style_bg_color(s_fav_btn, lv_color_hex(theme::ACCENT_DIM), 0);
}
void UITask::channelSet(uint8_t which, int v) {
using namespace chanview;
if (!chanctl::exists(s_idx) || !_prefs) return;
if (which == C_NOTIF) chanctl::setNotif(_prefs, s_idx, (uint8_t)v);
else if (which == C_SCOPE) the_mesh.setChannelScope(s_idx, (uint8_t)v);
else if (which == C_MELODY) { chanctl::setMelody(_prefs, s_idx, (uint8_t)v); hearMelody(v); }
the_mesh.savePrefs();
}
void UITask::channelAction(uint8_t act) {
using namespace chanview;
int idx = s_idx;
if (!chanctl::exists(idx)) { navClosePopup(); return; }
switch (act) {
case A_FAV: {
bool on = !chanctl::favourite(_prefs, idx);
chanctl::setFavourite(_prefs, idx, on);
the_mesh.savePrefs();
if (s_fav_btn) lv_obj_set_style_bg_color(s_fav_btn, lv_color_hex(on ? theme::ACCENT_DIM : theme::SURFACE), 0);
showToast(on ? "Added to favourites" : "Removed from favourites", 1200);
if (_screen == SCR_CHATS) { buildChats(); channelMenu(idx); } // list order / star
break;
}
case A_READ:
_core->history.setChUnread(idx, 0);
navClosePopup();
if (_screen == SCR_CHATS) buildChats();
break;
case A_EDIT:
navClosePopup();
showChannelEdit(idx);
break;
case A_PIN:
pinPopup(true, (uint8_t)idx, nullptr); // DeviceScreen.h
break;
case A_DELETE:
if (!s_del_armed_ms || millis() - s_del_armed_ms > 3000) { // second tap within 3 s confirms
s_del_armed_ms = millis() | 1;
if (s_del_lbl) lv_label_set_text(s_del_lbl, "Delete?");
break;
}
chanctl::remove(idx);
navClosePopup();
showToast("Channel deleted");
showChats();
break;
}
}
// ── Add / Edit ────────────────────────────────────────────────────────────────
static lv_obj_t* chanField(lv_obj_t* parent, const char* placeholder) {
lv_obj_t* ta = wifiField(parent, placeholder, false);
lv_obj_remove_event_cb(ta, onWifiField);
lv_obj_add_event_cb(ta, onChanField, LV_EVENT_CLICKED, NULL);
return ta;
}
void UITask::showChannelEdit(int idx) {
_screen = SCR_CHANNEL_EDIT;
chanview::s_idx = chanctl::exists(idx) ? idx : -1;
chanview::s_hex = false;
buildChannelEdit();
}
void UITask::buildChannelEdit() {
using namespace chanview;
bool edit = s_idx >= 0;
lv_obj_t* body = newScreen(edit ? "Edit channel" : "Add channel", true);
lv_obj_set_style_pad_row(body, 4, 0);
clearForm();
if (!edit) {
static const char* TYPES[] = { "Public", "Hashtag", "Private", "" };
s_types = segmented(body, TYPES, s_type, LV_PCT(100), 38, theme::SURFACE);
lv_obj_set_style_text_font(s_types, THEME_FONT_BODY, LV_PART_ITEMS);
lv_obj_add_event_cb(s_types, onChanType, LV_EVENT_VALUE_CHANGED, NULL);
}
s_hint = label(body, "", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(s_hint, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_hint, LV_PCT(100));
s_name = chanField(body, "Name");
lv_textarea_set_max_length(s_name, sizeof(((ChannelDetails*)0)->name) - 1);
s_secret = chanField(body, "Passphrase");
lv_textarea_set_max_length(s_secret, 32);
lv_obj_t* row = settingRow(body, "Hex key", "32 characters");
s_hex_sw = lv_switch_create(row);
lv_obj_set_size(s_hex_sw, 46, 24);
lv_obj_align(s_hex_sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
lv_obj_add_event_cb(s_hex_sw, onChanHex, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_t* save = lv_button_create(body);
lv_obj_set_size(save, LV_PCT(100), 38);
lv_obj_set_style_shadow_width(save, 0, 0);
lv_obj_set_style_radius(save, theme::RADIUS, 0);
lv_obj_set_style_bg_color(save, lv_color_hex(theme::ACCENT_DIM), 0);
lv_obj_add_event_cb(save, onChanSave, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(save, LV_SYMBOL_OK " Save", THEME_FONT_BODY, theme::TEXT));
stylePrimary(save);
s_status = label(body, "", THEME_FONT_SMALL, theme::FAIL);
lv_label_set_long_mode(s_status, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_status, LV_PCT(100));
if (edit) {
ChannelDetails ch;
char hex[33] = "";
the_mesh.getChannel(s_idx, ch);
chanctl::secretHex(s_idx, hex);
lv_textarea_set_text(s_name, ch.name);
lv_textarea_set_placeholder_text(s_secret, "New passphrase (empty: keep)");
lv_label_set_text_fmt(s_hint, "Key: %s", hex);
}
s_kb = kb::create(screen(), _prefs);
lv_obj_set_size(s_kb, LV_PCT(100), 124);
lv_obj_align(s_kb, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_add_event_cb(s_kb, onChanKb, LV_EVENT_READY, NULL);
lv_obj_add_event_cb(s_kb, onChanKb, LV_EVENT_CANCEL, NULL);
lv_obj_add_flag(s_kb, LV_OBJ_FLAG_HIDDEN);
channelEditType(edit ? T_PRIVATE : s_type);
}
// Show the fields the channel type needs.
void UITask::channelEditType(int type) {
using namespace chanview;
if (!s_name || type < T_PUBLIC || type > T_PRIVATE) return;
bool edit = s_idx >= 0;
if (!edit) s_type = (uint8_t)type;
channelEditKbHide();
auto show = [](lv_obj_t* o, bool on) {
if (on) lv_obj_remove_flag(o, LV_OBJ_FLAG_HIDDEN); else lv_obj_add_flag(o, LV_OBJ_FLAG_HIDDEN);
};
show(s_name, type != T_PUBLIC);
show(s_secret, type == T_PRIVATE);
show(lv_obj_get_parent(s_hex_sw), type == T_PRIVATE);
lv_label_set_text(s_status, "");
if (edit) return; // the hint shows the key
switch (type) {
case T_PUBLIC:
lv_label_set_text(s_hint, "The open channel every MeshCore device starts with. Adds it back if you removed it.");
break;
case T_HASHTAG:
lv_textarea_set_placeholder_text(s_name, "Topic, e.g. hiking");
lv_label_set_text(s_hint, "A public group anyone can join by typing the same topic. The key comes from the name.");
break;
default:
lv_textarea_set_placeholder_text(s_name, "Name");
lv_label_set_text(s_hint, "Readable only by people you give the key to.");
break;
}
}
void UITask::channelEditHex(bool hex) {
using namespace chanview;
s_hex = hex;
if (!s_secret) return;
lv_textarea_set_text(s_secret, ""); // a passphrase isn't a key and vice versa
lv_textarea_set_placeholder_text(s_secret, hex ? "32 hex characters" : (s_idx >= 0 ? "New passphrase (empty: keep)" : "Passphrase"));
lv_textarea_set_accepted_chars(s_secret, hex ? "0123456789abcdefABCDEF" : NULL);
}
void UITask::channelEditField(lv_obj_t* ta) {
using namespace chanview;
if (!s_kb) return;
lv_obj_remove_state(s_name, LV_STATE_FOCUSED);
lv_obj_remove_state(s_secret, LV_STATE_FOCUSED);
lv_obj_add_state(ta, LV_STATE_FOCUSED); // draws the cursor
lv_keyboard_set_textarea(s_kb, ta);
if (lv_obj_has_flag(s_kb, LV_OBJ_FLAG_HIDDEN)) {
lv_obj_remove_flag(s_kb, LV_OBJ_FLAG_HIDDEN);
if (_body) lv_obj_set_height(_body, lv_obj_get_height(_body) - lv_obj_get_height(s_kb));
}
lv_obj_update_layout(screen());
lv_obj_scroll_to_view(ta, LV_ANIM_OFF);
}
void UITask::channelEditKbHide() {
using namespace chanview;
if (!s_kb || lv_obj_has_flag(s_kb, LV_OBJ_FLAG_HIDDEN)) return;
lv_obj_add_flag(s_kb, LV_OBJ_FLAG_HIDDEN);
lv_obj_remove_state(s_name, LV_STATE_FOCUSED);
lv_obj_remove_state(s_secret, LV_STATE_FOCUSED);
if (_body) lv_obj_set_height(_body, lv_obj_get_height(_body) + lv_obj_get_height(s_kb));
}
void UITask::channelEditSave() {
using namespace chanview;
if (!s_name) return;
const char* name = lv_textarea_get_text(s_name);
const char* secret = lv_textarea_get_text(s_secret);
chanctl::Result r;
if (s_idx >= 0)
r = secret[0] ? chanctl::savePrivate(s_idx, name, secret, s_hex) : chanctl::rename(s_idx, name);
else if (s_type == T_PUBLIC) r = chanctl::addPublic();
else if (s_type == T_HASHTAG) r = chanctl::addHashtag(name);
else r = chanctl::savePrivate(-1, name, secret, s_hex);
if (r != chanctl::OK) { // say so over the keyboard, and put the cursor where the fix goes
const char* msg = (r == chanctl::NO_NAME && s_type == T_HASHTAG && s_idx < 0) ? "Topic required" : chanctl::resultText(r);
lv_label_set_text(s_status, msg);
showToast(msg, 2000);
if (r == chanctl::NO_NAME) channelEditField(s_name);
else if (r == chanctl::NO_SECRET || r == chanctl::BAD_SECRET) channelEditField(s_secret);
return;
}
channelEditKbHide();
showToast(s_idx >= 0 ? "Channel updated" : "Channel added", 1500);
showChats();
}