Files
MeshCore-Solo/examples/companion_radio/ui-lvgl/ChannelScreen.h
T
JakubandClaude Opus 5.5 23caa11f51 feat(ui-lvgl): power saving, review fixes, shared helpers
Power: the UI loop sleeps until something is due (never with packets
queued); screen off puts the CPU at 80 MHz, the GT911 to sleep (unless a
tap wakes the screen) and the LP5814 in standby. GPS off now cuts the
L76K's rail and UART on the L2 (L2GpsProvider). The battery divider is
powered only for a reading (measured: settles at once); readings within
2 s are reused. The SD mount is retried at most every 5 s.

Fixes: WiFi left on after leaving a scan, live tiles' WiFi kept with the
screen off on the map, an ADC read every second for the status bar, touch
polled on every pass while dark, a scan cutting an update's WiFi, relay
count not refreshing in a channel thread, raw @[nick] in room previews.
Labels skip unchanged text (no redraw).

Helpers: scrollList, dimOverlay, settingRow, flexBox, nvs::get/put,
trailFilePath (a second save in the same minute no longer overwrites),
unreadTotal. Dead code removed.

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

343 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),
(int)lv_dropdown_get_selected((lv_obj_t*)lv_event_get_target(e)));
}
static void onChanSegment(lv_event_t* e) { // user data: C_NOTIF / C_MELODY
s_ui->channelSet((uint8_t)(uintptr_t)lv_event_get_user_data(e),
(int)lv_buttonmatrix_get_selected_button((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);
// Notifications: three segments in the row (a dropdown's list ran off the
// bottom of the screen from a popup this low).
static const char* NOTIF[] = { "Default", "Muted", "Always", "" };
static const char* MELODY[] = { "Default", "Melody 1", "Melody 2", "" };
lv_obj_t* row = settingRow(panel, "Alerts", NULL);
radioview::rowSegmented(row, NOTIF, chanctl::notif(_prefs, idx), 200, onChanSegment, C_NOTIF);
row = settingRow(panel, "Sound", NULL);
radioview::rowSegmented(row, MELODY, chanctl::melody(_prefs, idx), 200, onChanSegment, C_MELODY);
const ScopeList& sl = the_mesh.scopeList();
if (sl.count > 0) { // only once regions are set up (Settings in the app)
row = settingRow(panel, "Scope", "Region it's sent in");
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));
lv_obj_t* dd = radioview::rowDropdown(row, s_scope_opts, chanctl::scope(_prefs, idx), 112, onChanDropdown, C_SCOPE);
lv_obj_set_style_max_height(lv_dropdown_get_list(dd), 100, 0); // stays on screen; scrolls if long
}
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();
}