Files
MeshCore-Solo/examples/companion_radio/ui-new/SettingsScreen.h
T
JakubandClaude Opus 5 1d5dbe2f9a feat(companion): contact expiry + prune, and scope-list follow-up fixes
Settings > Contacts gains "Expire" (Off/7d/30d/90d) and a "Prune now" action
that counts first and asks before removing anything. A contact with no
advert/update within the threshold is eligible; favourites are always kept.
Thresholds and their labels live in one NodePrefs table so the age shown and
the age enforced can't drift. SCHEMA_SENTINEL -> 0xC0DE002C (sizeof unchanged
at 2824, confirmed on native and a real WioTrackerL1 build).

Also fixes four bugs in the scope list from 89c02eea:

- removeScope() saved /scopes1 but never savePrefs(), so the ch_scope_idx[]
  and repeat_extra_scope_mask fix-ups it makes were lost on reboot, leaving
  shifted entries against unshifted indices.
- CMD_SET_DEFAULT_FLOOD_SCOPE wrote the legacy fields directly instead of
  going through setPrimaryScope(), so the app's default-scope setting had
  nothing reading it once sends resolved through the list.
- The reverse direction was stale too: an on-device "Set as default" never
  refreshed default_scope_name/key, so CMD_GET_DEFAULT_FLOOD_SCOPE reported
  a scope the device had stopped using. New syncLegacyDefaultScope().
- Upgrading from the old single Scope field set the default (so DMs kept it)
  but left every channel unscoped, since "*" means unscoped, not "inherit".
  loadScopeList() now reports when it migrated and begin() seeds the channels
  that already exist, leaving empty slots alone.

"Set default" -> "Set as default", and setting it now says what it governs
("Default: DMs + relay") rather than leaving the [default] marker to imply
more than it does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:55:46 +02:00

1299 lines
50 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#pragma once
// Custom screen — not part of upstream UITask.cpp
// Included by UITask.cpp after SensorPlaceholders.h is defined.
#include "../Features.h"
#include "../RadioPresets.h"
#include "RadioParamsEditor.h"
#include "RadioPresetPicker.h"
#include "AccordionList.h"
#include "PopupMenu.h" // scope list management's per-row action menu
class SettingsScreen : public UIScreen {
UITask* _task;
enum SettingItem {
// Display section
SECTION_DISPLAY,
#if FEAT_BRIGHTNESS_SETTING
BRIGHTNESS,
#endif
#if AUTO_OFF_MILLIS > 0
AUTO_OFF,
#endif
AUTO_LOCK,
BATT_DISPLAY,
#if FEAT_CLOCK_SECONDS_SETTING
CLOCK_SECONDS,
#endif
CLOCK_FORMAT,
#if FEAT_DISPLAY_ROTATION_SETTING
ROTATION,
#endif
#if FEAT_JOYSTICK_ROTATION_SETTING
JOY_ROTATION,
#endif
#if FEAT_FULL_REFRESH_SETTING
EINK_FULL_REFRESH,
#endif
MSG_WAKE,
// Sound section
SECTION_SOUND,
BUZZER,
BUZZER_VOLUME,
DM_MELODY,
CH_MELODY,
AD_SOUND,
AD_SOUND_SCOPE,
// Home pages section
SECTION_HOME_PAGES,
HOME_CLOCK, HOME_FAVOURITES, HOME_RADIO, HOME_BT, HOME_ADVERT,
#if ENV_INCLUDE_GPS == 1
HOME_GPS,
#endif
#if UI_SENSORS_PAGE == 1
HOME_SENSORS,
#endif
HOME_SETTINGS, HOME_QUICK_MSG,
HOME_TOOLS, HOME_SHUTDOWN, HOME_MAP,
// Radio section
SECTION_RADIO,
TX_POWER,
RADIO_PRESET,
CUSTOM_FREQ, CUSTOM_SF, CUSTOM_BW, CUSTOM_CR,
POWER_SAVE,
TX_APC,
SCOPE_NAME,
// System section
SECTION_SYSTEM,
DEVICE_NAME,
TIMEZONE,
LOW_BAT,
#if ENV_INCLUDE_GPS == 1
GPS_DUTY_CYCLE,
#endif
UNITS,
REBOOT,
// Keyboard section
SECTION_KEYBOARD,
KEYBOARD_TYPE,
KEYBOARD_MAIN_ALPHABET,
KEYBOARD_ALPHABET,
#if defined(CARDKB_I2C)
KEYBOARD_CARDKB_COMPACT,
#endif
// Contacts section
SECTION_CONTACTS, DM_FILTER, CH_FILTER, ROOM_FILTER, FAV_SORT, EXPIRE_AFTER, PRUNE_NOW,
// Messages section
SECTION_MESSAGES,
DM_RESEND,
MSG_SLOT_0, MSG_SLOT_1, MSG_SLOT_2, MSG_SLOT_3, MSG_SLOT_4,
MSG_SLOT_5, MSG_SLOT_6, MSG_SLOT_7, MSG_SLOT_8, MSG_SLOT_9,
Count
};
// Cursor + scroll, fold state and the flattened visible list are owned by the
// shared AccordionList helper. We keep only the section→SettingItem mapping it
// needs (sections are walked once from the enum, honouring the #if guards).
int _selected = 0; // SettingItem under the cursor, resolved per input/render
int _reserve = 0; // right-edge px reserved for the scrollbar (0 when list fits)
bool _dirty = false;
AccordionList _acc;
static const int NUM_SECTIONS = 8;
static const int MAX_PER_SEC = 16;
uint8_t _sec_items[NUM_SECTIONS][MAX_PER_SEC]; // SettingItem per (section, row)
uint8_t _sec_count[NUM_SECTIONS];
uint8_t _sec_header[NUM_SECTIONS]; // the SECTION_* enum for each section
int _num_sections = 0;
#if AUTO_OFF_MILLIS > 0
static const uint16_t AUTO_OFF_OPTS[5];
static const char* AUTO_OFF_LABELS[5];
static const int AUTO_OFF_COUNT = 5;
#endif
static const uint16_t LOW_BAT_OPTS[7];
static const char* LOW_BAT_LABELS[7];
static const int LOW_BAT_COUNT = 7;
#if ENV_INCLUDE_GPS == 1
// GPS duty-cycle sleep window: how long GPS naps between fix acquisitions.
// "OFF" (0) keeps it continuously on, today's behaviour. Backed by
// NodePrefs::gps_interval, seconds.
static const uint32_t GPS_DUTY_OPTS[6];
static const char* GPS_DUTY_LABELS[6];
static const int GPS_DUTY_COUNT = 6;
int gpsDutyIndex() {
NodePrefs* p = _task->getNodePrefs();
if (!p) return 0;
for (int i = 0; i < GPS_DUTY_COUNT; i++)
if (GPS_DUTY_OPTS[i] == p->gps_interval) return i;
return 0;
}
#endif
static const char* BATT_DISPLAY_LABELS[3];
static const int BATT_DISPLAY_COUNT = 3;
static const char* SOUND_LABELS[4];
static const int SOUND_COUNT = 4;
static const char* AD_SCOPE_LABELS[2];
static const int AD_SCOPE_COUNT = 2;
// ("Expire" reads its labels straight from NodePrefs::contactExpiryLabel(),
// which is also where MyMesh takes the matching day count from.)
#if FEAT_FULL_REFRESH_SETTING
static const char* EINK_FULL_REFRESH_LABELS[5];
static const int EINK_FULL_REFRESH_COUNT = 5;
#endif
int lowBatIndex() {
NodePrefs* p = _task->getNodePrefs();
if (!p) return 0;
for (int i = 0; i < LOW_BAT_COUNT; i++)
if (LOW_BAT_OPTS[i] == p->low_batt_mv) return i;
return 0;
}
// The companion's own radio fields, as the shared preset picker's target
// (Tools Repeater points the same picker at the dedicated repeater profile).
RadioPresetPicker::Target radioTarget(NodePrefs* p) const {
return { &p->freq, &p->bw, &p->sf, &p->cr };
}
// Value column start, pulled left by the scrollbar gutter so right-side
// values never render under the indicator when the list scrolls.
int valCol(DisplayDriver& display) const { return display.valCol() - _reserve; }
// Shared 0/90/180/270 labels for display + joystick rotation.
static const char* rotLabel(uint8_t r) {
static const char* const L[] = { "0 deg", "90 deg", "180 deg", "270 deg" };
return L[r & 3];
}
void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) {
const int gap = 2;
const int avail = display.width() - x - _reserve;
const int raw = (avail - (max_val - 1) * gap) / max_val;
const int cap = display.getLineHeight() - 2;
const int box_h = raw < cap ? (raw < 2 ? 2 : raw) : cap;
const int box_w = box_h;
for (int i = 0; i < max_val; i++) {
int bx = x + i * (box_w + gap);
display.drawRect(bx, y, box_w, box_h);
if (i < value)
display.fillRect(bx + 1, y + 1, box_w - 2, box_h - 2);
}
}
#if AUTO_OFF_MILLIS > 0
int autoOffIndex() {
NodePrefs* p = _task->getNodePrefs();
if (!p) return 1;
for (int i = 0; i < AUTO_OFF_COUNT; i++)
if (AUTO_OFF_OPTS[i] == p->auto_off_secs) return i;
return 1;
}
#endif
bool isSection(int item) const {
return item == SECTION_DISPLAY || item == SECTION_SOUND ||
item == SECTION_HOME_PAGES ||
item == SECTION_RADIO || item == SECTION_SYSTEM ||
item == SECTION_KEYBOARD ||
item == SECTION_CONTACTS || item == SECTION_MESSAGES;
}
const char* sectionName(int item) const {
if (item == SECTION_DISPLAY) return "Display";
if (item == SECTION_SOUND) return "Sound";
if (item == SECTION_HOME_PAGES) return "Home Pages";
if (item == SECTION_RADIO) return "Radio";
if (item == SECTION_SYSTEM) return "System";
if (item == SECTION_KEYBOARD) return "Keyboard";
if (item == SECTION_CONTACTS) return "Contacts";
if (item == SECTION_MESSAGES) return "Messages";
return "";
}
// Walk the SettingItem enum once, bucketing items under their section header.
// #if-guarded items need no special handling — they simply aren't in the enum.
void buildSections() {
int cur = -1;
for (int i = 0; i < (int)Count; i++) {
if (isSection(i)) {
if (++cur >= NUM_SECTIONS) break;
_sec_header[cur] = (uint8_t)i;
_sec_count[cur] = 0;
} else if (cur >= 0 && _sec_count[cur] < MAX_PER_SEC) {
_sec_items[cur][_sec_count[cur]++] = (uint8_t)i;
}
}
_num_sections = cur + 1;
}
// (Re)load the section sizes into the accordion (folds all, resets the cursor).
void resetList() {
uint8_t sizes[NUM_SECTIONS];
for (int i = 0; i < _num_sections; i++) sizes[i] = _sec_count[i];
_acc.begin(sizes, _num_sections);
}
// Resolve the accordion's selected row to a SettingItem (header → SECTION_*).
int currentItem() const {
const AccordionList::Row& r = _acc.selected();
return (r.item < 0) ? _sec_header[r.sec] : _sec_items[r.sec][r.item];
}
bool isHomePage(int item) const {
return item == HOME_CLOCK || item == HOME_RADIO || item == HOME_BT ||
item == HOME_ADVERT || item == HOME_TOOLS ||
item == HOME_SHUTDOWN || item == HOME_SETTINGS || item == HOME_QUICK_MSG ||
item == HOME_FAVOURITES || item == HOME_MAP
#if ENV_INCLUDE_GPS == 1
|| item == HOME_GPS
#endif
#if UI_SENSORS_PAGE == 1
|| item == HOME_SENSORS
#endif
;
}
uint16_t homePageBit(int item) const {
int bit = homePageBitIndex(item);
// SETTINGS and QUICK_MSG are always visible (no mask bit). All other pages
// — including FAVOURITES — toggle via home_pages_mask.
if (bit < 0 || bit == NodePrefs::HPB_SETTINGS || bit == NodePrefs::HPB_QUICK_MSG) return 0;
return (uint16_t)(1 << bit);
}
const char* homePageLabel(int item) const {
int bit = homePageBitIndex(item);
return NodePrefs::homePageLabel((uint8_t)(bit >= 0 ? bit : NodePrefs::HPB_COUNT));
}
bool homePageVisible(int item, const NodePrefs* p) const {
if (item == HOME_SETTINGS || item == HOME_QUICK_MSG) return true;
uint16_t bit = homePageBit(item);
if (!bit) return false;
uint16_t mask = (p && p->home_pages_mask) ? p->home_pages_mask : NodePrefs::HP_ALL;
return (mask & bit) != 0;
}
bool homePageToggleable(int item) const {
return item != HOME_SETTINGS && item != HOME_QUICK_MSG;
}
// Returns the bit-index used in page_order for this SettingItem, or -1.
// Bit-index values are defined once in NodePrefs::HomePageBit.
int homePageBitIndex(int item) const {
if (item == HOME_CLOCK) return NodePrefs::HPB_CLOCK;
if (item == HOME_FAVOURITES) return NodePrefs::HPB_FAVOURITES;
if (item == HOME_RADIO) return NodePrefs::HPB_RADIO;
if (item == HOME_BT) return NodePrefs::HPB_BLUETOOTH;
if (item == HOME_ADVERT) return NodePrefs::HPB_ADVERT;
#if ENV_INCLUDE_GPS == 1
if (item == HOME_GPS) return NodePrefs::HPB_GPS;
#endif
#if UI_SENSORS_PAGE == 1
if (item == HOME_SENSORS) return NodePrefs::HPB_SENSORS;
#endif
if (item == HOME_TOOLS) return NodePrefs::HPB_TOOLS;
if (item == HOME_SHUTDOWN) return NodePrefs::HPB_SHUTDOWN;
if (item == HOME_MAP) return NodePrefs::HPB_MAP;
if (item == HOME_SETTINGS) return NodePrefs::HPB_SETTINGS;
if (item == HOME_QUICK_MSG) return NodePrefs::HPB_QUICK_MSG;
return -1;
}
// Returns 1-based position of item in page_order, or 0 if no custom order / not found.
int homePagePosition(int item, const NodePrefs* p) const {
if (!p || p->page_order_set != NodePrefs::PAGE_ORDER_MAGIC) return 0;
int bit = homePageBitIndex(item);
if (bit < 0) return 0;
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = p->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
if ((int)(v - 1) == bit) return i + 1;
}
return 0;
}
// Initialises page_order to the default display sequence if not already set.
// Also repairs a partially-initialised order where CLOCK is absent; migrates
// older orders by inserting FAVOURITES after CLOCK; and appends any pages that
// are absent from a stale saved order (e.g. TOOLS/MESSAGES added by later firmware).
void ensurePageOrderInit(NodePrefs* p) const {
if (!p) return;
if (p->page_order_set == NodePrefs::PAGE_ORDER_MAGIC) {
bool has_clock = false;
bool has_fav = false;
int len = 0;
int clock_at = -1;
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = p->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
if ((int)(v - 1) == NodePrefs::HPB_CLOCK) { has_clock = true; clock_at = i; }
if ((int)(v - 1) == NodePrefs::HPB_FAVOURITES) { has_fav = true; }
len = i + 1;
}
if (!has_clock) {
// Corrupted/partial — full re-init below.
memset(p->page_order, 0, sizeof(p->page_order));
} else {
if (!has_fav) {
// Insert FAVOURITES right after CLOCK. Real orders are shorter than
// PAGE_ORDER_LEN so there's room; only a pathologically full order would
// drop its last entry, which buildVisibleOrder's fallback re-appends.
int insert_at = clock_at + 1;
// Guard against a saved order with all PAGE_ORDER_LEN slots already
// valid and CLOCK in the last one: insert_at would be PAGE_ORDER_LEN,
// one past the array, and the shift loop below wouldn't run (tail is
// clamped to the last index) to catch it -- the write would land one
// byte past page_order, into whatever NodePrefs field follows.
if (insert_at < NodePrefs::PAGE_ORDER_LEN) {
int tail = (len < NodePrefs::PAGE_ORDER_LEN) ? len : NodePrefs::PAGE_ORDER_LEN - 1;
for (int i = tail; i > insert_at; i--) p->page_order[i] = p->page_order[i - 1];
p->page_order[insert_at] = NodePrefs::HPB_FAVOURITES + 1;
}
}
// Append any pages that are absent from the saved order (e.g. added by a
// later firmware version). Recount first since the block above may have
// just inserted FAVOURITES.
{
uint16_t present = 0;
int cur_len = 0;
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = p->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
present |= (uint16_t)(1u << (v - 1));
cur_len++;
}
// Every page has a slot now (PAGE_ORDER_LEN == HPB_COUNT), so all pages
// are required — any missing from a stale saved order (SHUTDOWN and MAP
// for pre-0x0019 upgraders) is appended into the free tail slots below.
static const uint8_t REQUIRED[] = {
NodePrefs::HPB_CLOCK, NodePrefs::HPB_FAVOURITES,
NodePrefs::HPB_RECENT, NodePrefs::HPB_RADIO,
NodePrefs::HPB_BLUETOOTH, NodePrefs::HPB_ADVERT,
#if ENV_INCLUDE_GPS == 1
NodePrefs::HPB_GPS,
#endif
#if UI_SENSORS_PAGE == 1
NodePrefs::HPB_SENSORS,
#endif
NodePrefs::HPB_SETTINGS, NodePrefs::HPB_MAP, NodePrefs::HPB_TOOLS,
NodePrefs::HPB_QUICK_MSG, NodePrefs::HPB_SHUTDOWN,
};
for (int ri = 0; ri < (int)(sizeof(REQUIRED)/sizeof(REQUIRED[0])); ri++) {
uint8_t bit = REQUIRED[ri];
if (!(present & (uint16_t)(1u << bit)) && cur_len < NodePrefs::PAGE_ORDER_LEN)
p->page_order[cur_len++] = bit + 1;
}
}
return;
}
}
// Default: CLOCK FAVOURITES RECENT RADIO BT ADVERT [GPS] [SENSORS] SETTINGS
// MAP TOOLS MESSAGES SHUTDOWN — mirrors the home-carousel enum order. Every
// page has an explicit slot now (PAGE_ORDER_LEN == HPB_COUNT).
int j = 0;
p->page_order[j++] = NodePrefs::HPB_CLOCK + 1;
p->page_order[j++] = NodePrefs::HPB_FAVOURITES + 1;
p->page_order[j++] = NodePrefs::HPB_RECENT + 1;
p->page_order[j++] = NodePrefs::HPB_RADIO + 1;
p->page_order[j++] = NodePrefs::HPB_BLUETOOTH + 1;
p->page_order[j++] = NodePrefs::HPB_ADVERT + 1;
#if ENV_INCLUDE_GPS == 1
p->page_order[j++] = NodePrefs::HPB_GPS + 1;
#endif
#if UI_SENSORS_PAGE == 1
p->page_order[j++] = NodePrefs::HPB_SENSORS + 1;
#endif
p->page_order[j++] = NodePrefs::HPB_SETTINGS + 1;
p->page_order[j++] = NodePrefs::HPB_MAP + 1;
p->page_order[j++] = NodePrefs::HPB_TOOLS + 1;
p->page_order[j++] = NodePrefs::HPB_QUICK_MSG + 1;
p->page_order[j++] = NodePrefs::HPB_SHUTDOWN + 1;
while (j < NodePrefs::PAGE_ORDER_LEN) p->page_order[j++] = 0;
p->page_order_set = NodePrefs::PAGE_ORDER_MAGIC;
}
// Swaps item's page_order slot with its neighbour in the given direction (-1=earlier, +1=later).
void movePageInOrder(int item, int delta, NodePrefs* p) {
ensurePageOrderInit(p);
int bit = homePageBitIndex(item);
if (bit < 0) return;
int cur = -1, total = 0;
for (int i = 0; i < NodePrefs::PAGE_ORDER_LEN; i++) {
uint8_t v = p->page_order[i];
if (v < 1 || v > NodePrefs::HPB_COUNT) break;
if ((int)(v - 1) == bit) cur = i;
total++;
}
if (cur < 0) return;
int next = cur + delta;
if (next < 0 || next >= total) return;
uint8_t tmp = p->page_order[cur];
p->page_order[cur] = p->page_order[next];
p->page_order[next] = tmp;
}
bool isMsgSlot(int item) const {
return item >= MSG_SLOT_0 && item <= MSG_SLOT_9;
}
int msgSlotIndex(int item) const {
return item - MSG_SLOT_0;
}
// Returns 0, or the ms until the selected row's value should next redraw
// to keep a marquee animation going (see DisplayDriver::drawTextEllipsized).
int renderItem(DisplayDriver& display, int item, int y, bool sel) {
NodePrefs* p = _task->getNodePrefs();
int mq_delay = 0;
drawRowSelection(display, y, sel, _reserve);
display.setCursor(2, y);
#if FEAT_BRIGHTNESS_SETTING
if (item == BRIGHTNESS) {
display.print("Bright");
renderBar(display, valCol(display), y, (p ? p->display_brightness : 2) + 1, 5);
} else
#endif
if (item == BUZZER) {
display.print("Buzzer");
display.setCursor(valCol(display), y);
#ifdef PIN_BUZZER
{ static const char* labels[] = { "ON", "OFF", "Auto" };
int m = _task->getBuzzerMode();
display.print(labels[m < 3 ? m : 0]); }
#else
display.print("N/A");
#endif
} else if (item == BUZZER_VOLUME) {
display.print("Buzzer vol");
#ifdef PIN_BUZZER
renderBar(display, valCol(display), y, _task->getBuzzerVolume() + 1, 5);
#else
display.setCursor(valCol(display), y);
display.print("N/A");
#endif
} else if (item == DM_MELODY) {
display.print("DM sound");
display.setCursor(valCol(display), y);
{ uint8_t v = p ? p->notif_melody_dm : 0;
display.print(SOUND_LABELS[v < SOUND_COUNT ? v : 0]); }
} else if (item == CH_MELODY) {
display.print("Ch sound");
display.setCursor(valCol(display), y);
{ uint8_t v = p ? p->notif_melody_ch : 0;
display.print(SOUND_LABELS[v < SOUND_COUNT ? v : 0]); }
} else if (item == AD_SOUND) {
display.print("AD sound");
display.setCursor(valCol(display), y);
{ uint8_t v = p ? p->notif_melody_ad : 0;
display.print(SOUND_LABELS[v < SOUND_COUNT ? v : 0]); }
} else if (item == AD_SOUND_SCOPE) {
display.print("AD scope");
display.setCursor(valCol(display), y);
{ uint8_t v = p ? p->advert_sound_scope : ADVERT_SOUND_SCOPE_ALL;
display.print(AD_SCOPE_LABELS[v < AD_SCOPE_COUNT ? v : 0]); }
} else if (item == MSG_WAKE) {
display.print("Msg wake");
display.setCursor(valCol(display), y);
display.print((p && p->msg_wake_screen_off) ? "OFF" : "ON");
} else if (isHomePage(item)) {
if (p) ensurePageOrderInit(p);
int pos = homePagePosition(item, p);
if (pos > 0) {
char pb[5]; snprintf(pb, sizeof(pb), "%2d ", pos);
display.print(pb);
}
display.print(homePageLabel(item));
display.setCursor(display.width() - 6 * display.getCharWidth() - _reserve, y);
if (!homePageToggleable(item))
display.print("always");
else
display.print(homePageVisible(item, p) ? "ON" : "OFF");
} else if (item == TX_POWER) {
display.print("TX Pwr");
char buf[8];
snprintf(buf, sizeof(buf),"%ddBm", p ? p->tx_power_dbm : 0);
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == RADIO_PRESET) {
display.print("Preset");
const char* name = p ? _picker.currentName(p, radioTarget(p)) : "Custom";
int xc = valCol(display);
int r = display.drawTextEllipsized(xc, y, display.width() - xc - _reserve, name, sel);
if (sel && r > 0) mq_delay = r;
} else if (item == CUSTOM_FREQ) {
display.print("Freq");
int xc = valCol(display);
if (sel && _editor.active()) {
_editor.render(display, xc, y);
} else {
char buf[10];
snprintf(buf, sizeof(buf), "%.3f", p ? p->freq : 0.0f);
display.setCursor(xc, y);
display.print(buf);
}
} else if (item == CUSTOM_SF) {
display.print("SF");
char buf[6];
snprintf(buf, sizeof(buf), "%d", p ? (int)p->sf : 0);
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == CUSTOM_BW) {
display.print("BW");
char buf[10];
snprintf(buf, sizeof(buf), "%.1f", p ? p->bw : 0.0f);
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == CUSTOM_CR) {
display.print("CR");
char buf[6];
snprintf(buf, sizeof(buf), "%d", p ? (int)p->cr : 0);
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == POWER_SAVE) {
display.print("Pwr save");
display.setCursor(valCol(display), y);
// Forced off (and locked) while the repeater is on — it must hear all traffic.
if (p && p->client_repeat) display.print("--");
else display.print((p && p->rx_powersave) ? "ON" : "OFF");
} else if (item == TX_APC) {
display.print("Auto pwr");
display.setCursor(valCol(display), y);
// Suppressed (and locked) while repeating — a repeater holds full TX power.
if (p && p->client_repeat) display.print("--");
else display.print((p && p->tx_apc) ? "ON" : "OFF");
} else if (item == SCOPE_NAME) {
display.print("Scope");
int vx = valCol(display);
const ScopeList& sl = the_mesh.scopeList();
int r = display.drawTextEllipsized(vx, y, display.width() - vx - _reserve,
sl.name(sl.default_idx), sel);
if (sel && r > 0) mq_delay = r;
#if AUTO_OFF_MILLIS > 0
} else if (item == AUTO_OFF) {
display.print("Auto off");
display.setCursor(valCol(display), y);
display.print(AUTO_OFF_LABELS[autoOffIndex()]);
#endif
} else if (item == AUTO_LOCK) {
display.print("Auto lock");
display.setCursor(valCol(display), y);
display.print((p && p->auto_lock) ? "ON" : "OFF");
} else if (item == TIMEZONE) {
display.print("Time zone");
char buf[8];
int8_t tz = p ? p->tz_offset_hours : 0;
if (tz >= 0) snprintf(buf, sizeof(buf),"UTC+%d", (int)tz);
else snprintf(buf, sizeof(buf),"UTC%d", (int)tz);
display.setCursor(valCol(display), y);
display.print(buf);
} else if (item == LOW_BAT) {
display.print("Low batt");
display.setCursor(valCol(display), y);
display.print(LOW_BAT_LABELS[lowBatIndex()]);
#if ENV_INCLUDE_GPS == 1
} else if (item == GPS_DUTY_CYCLE) {
display.print("GPS pwr");
display.setCursor(valCol(display), y);
display.print(GPS_DUTY_LABELS[gpsDutyIndex()]);
#endif
} else if (item == UNITS) {
display.print("Units");
display.setCursor(valCol(display), y);
display.print((p && p->units_imperial) ? "Imperial" : "Metric");
} else if (item == DEVICE_NAME) {
display.print("Name");
int vx = valCol(display);
int r = display.drawTextEllipsized(vx, y, display.width() - vx - _reserve, the_mesh.getNodeName(), sel);
if (sel && r > 0) mq_delay = r;
} else if (item == REBOOT) {
display.print("Reboot"); // action row: Enter reboots this device
} else if (item == KEYBOARD_TYPE) {
display.print("Type");
display.setCursor(valCol(display), y);
display.print((p && p->keyboard_type) ? "T9" : "ABC");
} else if (item == KEYBOARD_MAIN_ALPHABET) {
display.print("Main");
display.setCursor(valCol(display), y);
display.print(NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_main_alphabet : 0));
} else if (item == KEYBOARD_ALPHABET) {
display.print("Additional");
display.setCursor(valCol(display), y);
display.print(NodePrefs::keyboardAlphabetLabel(p ? p->keyboard_alt_alphabet : 0));
#if defined(CARDKB_I2C)
} else if (item == KEYBOARD_CARDKB_COMPACT) {
display.print("Ext. KB");
display.setCursor(valCol(display), y);
display.print((p && p->keyboard_cardkb_compact) ? "Compact" : "Full");
#endif
} else if (item == BATT_DISPLAY) {
display.print("Batt disp");
display.setCursor(valCol(display), y);
uint8_t mode = p ? p->batt_display_mode : 0;
display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]);
#if FEAT_CLOCK_SECONDS_SETTING
} else if (item == CLOCK_SECONDS) {
display.print("Seconds");
display.setCursor(valCol(display), y);
display.print((p && p->clock_hide_seconds) ? "OFF" : "ON");
#endif
} else if (item == CLOCK_FORMAT) {
display.print("Format");
display.setCursor(valCol(display), y);
display.print((p && p->clock_12h) ? "12h" : "24h");
#if FEAT_DISPLAY_ROTATION_SETTING
} else if (item == ROTATION) {
display.print("Rotation");
display.setCursor(valCol(display), y);
display.print(rotLabel(p ? p->display_rotation : 0));
#endif
#if FEAT_JOYSTICK_ROTATION_SETTING
} else if (item == JOY_ROTATION) {
display.print("Joystick");
display.setCursor(valCol(display), y);
display.print(rotLabel(p ? p->joystick_rotation : 0));
#endif
#if FEAT_FULL_REFRESH_SETTING
} else if (item == EINK_FULL_REFRESH) {
display.print("Full rfsh");
display.setCursor(valCol(display), y);
{ uint8_t idx = p ? p->eink_full_refresh_every : 0;
if (idx >= EINK_FULL_REFRESH_COUNT) idx = 0;
display.print(EINK_FULL_REFRESH_LABELS[idx]); }
#endif
} else if (item == DM_FILTER) {
display.print("DMs");
display.setCursor(valCol(display), y);
display.print((p && p->dm_show_all) ? "All" : "Fav");
} else if (item == CH_FILTER) {
display.print("Channels");
display.setCursor(valCol(display), y);
display.print((p && p->ch_fav_only) ? "Fav" : "All");
} else if (item == ROOM_FILTER) {
display.print("Rooms");
display.setCursor(valCol(display), y);
display.print((p && p->room_fav_only) ? "Fav" : "All");
} else if (item == FAV_SORT) {
display.print("Favs top");
display.setCursor(valCol(display), y);
display.print((p && p->fav_sort_off) ? "OFF" : "ON");
} else if (item == EXPIRE_AFTER) {
display.print("Expire");
display.setCursor(valCol(display), y);
display.print(NodePrefs::contactExpiryLabel(p ? p->contact_expiry_idx : 0));
} else if (item == PRUNE_NOW) {
display.print("Prune now"); // action row: Enter counts + confirms + removes
} else if (item == DM_RESEND) {
display.print("Resend");
display.setCursor(valCol(display), y);
uint8_t n = p ? p->dm_resend_count : 0;
if (n == 0) display.print("OFF");
else { char buf[6]; snprintf(buf, sizeof(buf), "%ux", (unsigned)n); display.print(buf); }
} else if (isMsgSlot(item)) {
int slot = msgSlotIndex(item);
char label[5];
snprintf(label, sizeof(label), "Q%d:", slot + 1);
display.print(label);
const char* tmpl = (p && p->custom_msgs[slot][0]) ? p->custom_msgs[slot] : "(empty)";
int xm = 8 + display.getCharWidth() * 4;
int r = display.drawTextEllipsized(xm, y, display.width() - xm - _reserve, tmpl, sel);
if (sel && r > 0) mq_delay = r;
}
return mq_delay;
}
// Keyboard state for editing message slots
int _edit_slot = -1; // -1 = not editing, 0..9 = slot being edited
bool _edit_name = false; // editing DEVICE_NAME via the keyboard
KeyboardWidget* _kb;
// Scope list management (SCOPE_NAME row -> a full-screen add/rename/
// delete/set-default list over the shared named-scope list, ScopeList.h).
// Row 0 is always "*", rows 1..count are named entries, the trailing row
// is a synthetic "+ Add scope" -- same shape as MessagesScreen's channel
// picker. _scope_action_menu is the per-row Set default/Rename/Delete
// popup (Enter on a row); the keyboard (_kb) is reused for both Add and
// Rename's name entry, told apart by _scope_rename_idx (-1 = adding new).
bool _scope_mgmt_active = false;
int _scope_mgmt_sel = 0, _scope_mgmt_scroll = 0;
PopupMenu _scope_action_menu;
int _scope_action_idx = -1; // list index the open action menu targets
// -2 = _kb not open for a scope name; -1 = _kb is adding a new scope;
// >=0 = _kb is renaming that list index.
int _scope_rename_idx = -2;
bool _scope_delete_confirm_active = false;
// Contacts > "Prune now" confirm -- a separate PopupMenu from
// _scope_action_menu since this one pops up directly over the flat
// Settings list (PRUNE_NOW's own row), not nested inside a sub-screen.
PopupMenu _prune_confirm;
char _prune_confirm_title[40];
int renderScopeMgmt(DisplayDriver& display) {
display.setColor(DisplayDriver::LIGHT);
display.drawCenteredHeader("SCOPE", true, _scope_action_menu.active);
const ScopeList& sl = the_mesh.scopeList();
int total = sl.totalCount() + 1; // +1 synthetic "+ Add scope" row
drawList(display, total, _scope_mgmt_sel, _scope_mgmt_scroll, [&](int idx, int y, bool sel, int reserve) {
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
if (idx == sl.totalCount()) {
display.print("+ Add scope");
} else {
display.print(sl.name((uint8_t)idx));
if ((uint8_t)idx == sl.default_idx)
display.drawTextRightAlign(display.width() - reserve - 2, y, "[default]");
}
display.setColor(DisplayDriver::LIGHT);
});
if (_scope_action_menu.active) _scope_action_menu.render(display);
return _scope_action_menu.active ? 50 : 500;
}
// Radio preset picker — names are too long for the value column, so Enter on
// RADIO_PRESET opens it as a full-width scrollable list instead of cycling.
// Shared with Tools Repeater (see RadioPresetPicker.h). _picker.saving means
// _kb is open to name a new preset, not a message slot.
RadioPresetPicker _picker;
// Manual radio-parameter editing (digit-by-digit Freq editor + SF/BW/CR
// stepping), shared with Tools Repeater — see RadioParamsEditor.h.
RadioParamsEditor _editor;
public:
SettingsScreen(UITask* task, KeyboardWidget* kb)
: _task(task), _kb(kb) {
buildSections();
resetList();
}
void onShow() override {
_dirty = false;
_edit_name = false;
_scope_mgmt_active = false;
_scope_rename_idx = -2;
_scope_action_menu.active = false;
_scope_delete_confirm_active = false;
_prune_confirm.active = false;
resetList();
_editor.freq.active = false;
}
int render(DisplayDriver& display) override {
display.setTextSize(1);
if (_edit_slot >= 0 || _edit_name || _scope_rename_idx != -2 || _picker.saving) {
return _kb->render(display);
}
if (_scope_mgmt_active) return renderScopeMgmt(display);
display.drawCenteredHeader("SETTINGS");
int mq_delay = 0;
_acc.render(display,
// Section header: "[+/-] Name"
[&](int sec, int y, bool sel, int reserve, bool collapsed) {
_reserve = reserve;
display.setColor(DisplayDriver::LIGHT);
drawRowSelection(display, y, sel, reserve);
display.setCursor(2, y);
display.print(collapsed ? "+" : "-");
display.print(" ");
display.print(sectionName(_sec_header[sec]));
},
// Item row
[&](int sec, int item, int y, bool sel, int reserve) {
_reserve = reserve;
int r = renderItem(display, _sec_items[sec][item], y, sel);
if (r > 0) mq_delay = r;
});
if (_picker.menu.active) _picker.menu.render(display);
if (_prune_confirm.active) _prune_confirm.render(display);
return (mq_delay > 0 && mq_delay < 2000) ? mq_delay : 2000;
}
bool handleInput(char c) override {
NodePrefs* p = _task->getNodePrefs();
// Keyboard editing mode for message slots
if (_edit_slot >= 0) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
if (p) {
strncpy(p->custom_msgs[_edit_slot], _kb->buf, sizeof(p->custom_msgs[0]) - 1);
p->custom_msgs[_edit_slot][sizeof(p->custom_msgs[0]) - 1] = '\0';
_dirty = true;
}
_edit_slot = -1;
} else if (res == KeyboardWidget::CANCELLED) {
_edit_slot = -1;
}
return true;
}
// Keyboard editing mode for the device name
if (_edit_name) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
if (p) {
strncpy(p->node_name, _kb->buf, sizeof(p->node_name) - 1);
p->node_name[sizeof(p->node_name) - 1] = '\0';
_dirty = true; // savePrefsIfDirty on exit; getNodeName()/self-advert read node_name live
}
_edit_name = false;
} else if (res == KeyboardWidget::CANCELLED) {
_edit_name = false;
}
return true;
}
// Keyboard editing mode for adding/renaming a scope-list entry
if (_scope_rename_idx != -2) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
if (_scope_rename_idx == -1) the_mesh.addScope(_kb->buf);
else the_mesh.renameScope((uint8_t)_scope_rename_idx, _kb->buf);
_scope_rename_idx = -2;
} else if (res == KeyboardWidget::CANCELLED) {
_scope_rename_idx = -2;
}
return true;
}
if (_scope_mgmt_active) {
const ScopeList& sl = the_mesh.scopeList();
if (_scope_delete_confirm_active) {
auto res = _scope_action_menu.handleInput(c);
if (res == PopupMenu::SELECTED && _scope_action_menu.selectedIndex() == 0) { // "Delete"
the_mesh.removeScope((uint8_t)_scope_action_idx);
if (_scope_mgmt_sel > sl.totalCount()) _scope_mgmt_sel = sl.totalCount();
}
if (res != PopupMenu::NONE) _scope_delete_confirm_active = false;
return true;
}
if (_scope_action_menu.active) {
auto res = _scope_action_menu.handleInput(c);
if (res == PopupMenu::SELECTED) {
int sel = _scope_action_menu.selectedIndex();
if (sel == 0) { // Set as default
the_mesh.setDefaultScope((uint8_t)_scope_action_idx);
// "Default" is the protocol's own word (CMD_SET_DEFAULT_FLOOD_SCOPE,
// and what the app shows), but on its own it doesn't say default
// for *what* -- channels carry their own pick and don't inherit it.
// Spell out the two things it actually governs, at the moment the
// user sets it, rather than leaving the list marker to imply more.
_task->showAlert("Default: DMs + relay", 1400);
} else if (sel == 1 && _scope_action_idx >= 1) { // Rename
_scope_rename_idx = _scope_action_idx;
_kb->begin(sl.name((uint8_t)_scope_action_idx), 23);
_kb->clearPlaceholders();
} else if (sel == 2 && _scope_action_idx >= 1) { // Delete -- confirm first
_scope_action_menu.beginConfirm("Delete scope?", "Delete");
_scope_delete_confirm_active = true;
}
}
return true;
}
if (c == KEY_CANCEL) { _scope_mgmt_active = false; return true; }
int total = sl.totalCount() + 1;
if (c == KEY_UP) { _scope_mgmt_sel = (_scope_mgmt_sel > 0) ? _scope_mgmt_sel - 1 : total - 1; return true; }
if (c == KEY_DOWN) { _scope_mgmt_sel = (_scope_mgmt_sel + 1 < total) ? _scope_mgmt_sel + 1 : 0; return true; }
if (c == KEY_ENTER) {
if (_scope_mgmt_sel == sl.totalCount()) { // "+ Add scope"
_scope_rename_idx = -1;
_kb->begin("", 23);
_kb->clearPlaceholders();
} else {
_scope_action_idx = _scope_mgmt_sel;
bool is_named = _scope_action_idx >= 1;
_scope_action_menu.begin("Scope", is_named ? 3 : 1);
_scope_action_menu.addItem("Set as default");
if (is_named) { _scope_action_menu.addItem("Rename"); _scope_action_menu.addItem("Delete"); }
}
return true;
}
return true; // list has focus -- swallow anything else rather than falling through
}
// Digit-by-digit Freq editor
if (_editor.active()) {
if (_editor.handleFreqInput(c) && p) { _task->applyRadioParams(); _dirty = true; }
return true;
}
// Keyboard editing mode for naming a new saved preset
if (_picker.saving) {
auto res = _kb->handleInput(c);
if (res == KeyboardWidget::DONE) {
if (p && _picker.save(p, _kb->buf, radioTarget(p))) {
_dirty = true;
_task->showAlert("Preset saved", 800);
}
_picker.saving = false;
} else if (res == KeyboardWidget::CANCELLED) {
_picker.saving = false;
}
return true;
}
// Radio preset popup (and its "delete a saved preset" sub-list)
if (_picker.menu.active) {
auto res = _picker.menu.handleInput(c);
if (res == PopupMenu::SELECTED && p) {
switch (_picker.onSelected(_picker.menu.selectedIndex(), p, radioTarget(p))) {
case RadioPresetPicker::START_SAVE:
_kb->begin("", (int)sizeof(p->user_radio_presets[0].name) - 1);
_kb->clearPlaceholders(); // {loc}/{time} are for messages, not preset names
break;
case RadioPresetPicker::APPLIED:
_task->applyRadioParams();
_dirty = true;
break;
case RadioPresetPicker::DELETED:
_dirty = true;
_task->showAlert("Preset deleted", 800);
break;
case RadioPresetPicker::NONE:
break;
}
} else if (res == PopupMenu::CANCELLED) {
_picker.deleting = false;
_picker.confirm_slot = -1;
}
return true;
}
// Contacts > "Prune now" confirm
if (_prune_confirm.active) {
auto res = _prune_confirm.handleInput(c);
if (res == PopupMenu::SELECTED && _prune_confirm.selectedIndex() == 0) { // "Remove"
int n = the_mesh.pruneStaleContacts();
char msg[24];
snprintf(msg, sizeof(msg), "Removed %d contact%s", n, n == 1 ? "" : "s");
_task->showAlert(msg, 1400);
}
return true;
}
if (c == KEY_CANCEL) {
_task->savePrefsIfDirty(_dirty);
_task->gotoHomeScreen();
return true;
}
// Up/down navigation and section fold/unfold live in the shared helper.
// Enter on an item returns ACTIVATED and falls through to the per-item logic
// below; left/right are ignored by the helper and likewise fall through.
AccordionList::Result ar = _acc.handleInput(c);
if (ar == AccordionList::HANDLED) return true;
_selected = currentItem();
bool right = keyIsNext(c);
bool left = keyIsPrev(c);
bool enter = (c == KEY_ENTER);
#if FEAT_BRIGHTNESS_SETTING
if (_selected == BRIGHTNESS) {
uint8_t lvl = _task->getBrightnessLevel();
if (right && lvl < 4) { _task->setBrightnessLevel(lvl + 1); _dirty = true; return true; }
if (left && lvl > 0) { _task->setBrightnessLevel(lvl - 1); _dirty = true; return true; }
return right || left;
}
#endif
if (_selected == BUZZER && (left || right || enter)) {
_task->cycleBuzzerMode();
_dirty = true;
return true;
}
if (_selected == BUZZER_VOLUME) {
#ifdef PIN_BUZZER
uint8_t lvl = _task->getBuzzerVolume();
if (right && lvl < 4) { _task->setBuzzerVolumeLevel(lvl + 1); _dirty = true; return true; }
if (left && lvl > 0) { _task->setBuzzerVolumeLevel(lvl - 1); _dirty = true; return true; }
#endif
return right || left;
}
if (_selected == DM_MELODY && p && (left || right || enter)) {
p->notif_melody_dm = (p->notif_melody_dm + (left ? SOUND_COUNT - 1 : 1)) % SOUND_COUNT;
_dirty = true; return true;
}
if (_selected == CH_MELODY && p && (left || right || enter)) {
p->notif_melody_ch = (p->notif_melody_ch + (left ? SOUND_COUNT - 1 : 1)) % SOUND_COUNT;
_dirty = true; return true;
}
if (_selected == AD_SOUND && p && (left || right || enter)) {
p->notif_melody_ad = (p->notif_melody_ad + (left ? SOUND_COUNT - 1 : 1)) % SOUND_COUNT;
_dirty = true; return true;
}
if (_selected == AD_SOUND_SCOPE && p && (left || right || enter)) {
p->advert_sound_scope ^= 1;
_dirty = true; return true;
}
if (_selected == MSG_WAKE && p && (left || right || enter)) {
p->msg_wake_screen_off ^= 1;
_dirty = true; return true;
}
if (isHomePage(_selected) && p) {
if (left || right) {
movePageInOrder(_selected, left ? -1 : 1, p);
_dirty = true;
return true;
}
if (enter && homePageToggleable(_selected)) {
if (!p->home_pages_mask) p->home_pages_mask = NodePrefs::HP_ALL;
p->home_pages_mask ^= homePageBit(_selected);
_dirty = true;
return true;
}
return enter;
}
if (_selected == TX_POWER && p) {
if (right && p->tx_power_dbm < 22) { p->tx_power_dbm++; _task->applyTxPower(); _dirty = true; return true; }
if (left && p->tx_power_dbm > 2) { p->tx_power_dbm--; _task->applyTxPower(); _dirty = true; return true; }
}
if (_selected == RADIO_PRESET && p && enter) {
_picker.open(p, radioTarget(p), "Radio Preset");
return true;
}
// Enter Freq's digit-by-digit editor. Bounds come from the radio driver
// itself (RadioLib's own validated range for this chip) so a digit can never
// be nudged to a value setFrequency() would reject.
if (_selected == CUSTOM_FREQ && p && enter) {
float min_mhz, max_mhz;
radio_driver.getFreqBounds(min_mhz, max_mhz);
_editor.beginFreq(p->freq, min_mhz, max_mhz);
return true;
}
int dir = right ? 1 : (left ? -1 : 0);
if (_selected == CUSTOM_SF && p && dir && RadioParamsEditor::stepSF(p->sf, dir)) { _task->applyRadioParams(); _dirty = true; return true; }
if (_selected == CUSTOM_BW && p && dir && RadioParamsEditor::stepBW(p->bw, dir)) { _task->applyRadioParams(); _dirty = true; return true; }
if (_selected == CUSTOM_CR && p && dir && RadioParamsEditor::stepCR(p->cr, dir)) { _task->applyRadioParams(); _dirty = true; return true; }
if (_selected == POWER_SAVE && p && (left || right || enter)) {
if (p->client_repeat) { _task->showAlert("Off while repeating", 900); return true; }
p->rx_powersave ^= 1;
_task->applyPowerSave();
_dirty = true;
return true;
}
if (_selected == TX_APC && p && (left || right || enter)) {
if (p->client_repeat) { _task->showAlert("Off while repeating", 900); return true; }
p->tx_apc ^= 1;
_task->applyApc();
_dirty = true;
return true;
}
#if AUTO_OFF_MILLIS > 0
if (_selected == AUTO_OFF && p) {
int idx = autoOffIndex();
if (right || enter) idx = (idx + 1) % AUTO_OFF_COUNT;
else if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT;
if (left || right || enter) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; }
}
#endif
if (_selected == AUTO_LOCK && p && (left || right || enter)) {
p->auto_lock ^= 1;
_dirty = true;
return true;
}
if (_selected == TIMEZONE && p) {
if (right && p->tz_offset_hours < 14) { p->tz_offset_hours++; _dirty = true; return true; }
if (left && p->tz_offset_hours > -12) { p->tz_offset_hours--; _dirty = true; return true; }
}
if (_selected == LOW_BAT && p) {
int idx = lowBatIndex();
if (right || enter) idx = (idx + 1) % LOW_BAT_COUNT;
else if (left) idx = (idx + LOW_BAT_COUNT - 1) % LOW_BAT_COUNT;
if (left || right || enter) { p->low_batt_mv = LOW_BAT_OPTS[idx]; _dirty = true; return true; }
}
#if ENV_INCLUDE_GPS == 1
if (_selected == GPS_DUTY_CYCLE && p && (left || right || enter)) {
int idx = gpsDutyIndex();
if (right || enter) idx = (idx + 1) % GPS_DUTY_COUNT;
else if (left) idx = (idx + GPS_DUTY_COUNT - 1) % GPS_DUTY_COUNT;
p->gps_interval = GPS_DUTY_OPTS[idx];
_task->applyGpsInterval();
_dirty = true;
return true;
}
#endif
if (_selected == UNITS && p && (left || right || enter)) {
p->units_imperial ^= 1;
_dirty = true;
return true;
}
if (_selected == DEVICE_NAME && p && enter) {
_edit_name = true;
_kb->begin(the_mesh.getNodeName(), (int)sizeof(p->node_name) - 1);
_kb->clearPlaceholders(); // a device name is literal, not a message
return true;
}
if (_selected == SCOPE_NAME && p && enter) {
_scope_mgmt_active = true;
_scope_mgmt_sel = 0;
_scope_mgmt_scroll = 0;
return true;
}
if (_selected == REBOOT && enter) {
_task->showAlert("Rebooting...", 800);
_task->shutdown(true); // flushes prefs/RTC/contacts/trail, then reboots -- single choke point
return true;
}
if (_selected == KEYBOARD_TYPE && p && (left || right || enter)) {
p->keyboard_type ^= 1;
_dirty = true;
return true;
}
if (_selected == KEYBOARD_MAIN_ALPHABET && p && (left || right || enter)) {
int idx = p->keyboard_main_alphabet;
if (right || enter) idx = (idx + 1) % NodePrefs::KB_ALPHABET_COUNT;
else if (left) idx = (idx + NodePrefs::KB_ALPHABET_COUNT - 1) % NodePrefs::KB_ALPHABET_COUNT;
p->keyboard_main_alphabet = (uint8_t)idx;
_dirty = true;
return true;
}
if (_selected == KEYBOARD_ALPHABET && p && (left || right || enter)) {
int idx = p->keyboard_alt_alphabet;
if (right || enter) idx = (idx + 1) % NodePrefs::KB_ALPHABET_COUNT;
else if (left) idx = (idx + NodePrefs::KB_ALPHABET_COUNT - 1) % NodePrefs::KB_ALPHABET_COUNT;
p->keyboard_alt_alphabet = (uint8_t)idx;
_dirty = true;
return true;
}
#if defined(CARDKB_I2C)
if (_selected == KEYBOARD_CARDKB_COMPACT && p && (left || right || enter)) {
p->keyboard_cardkb_compact ^= 1;
_dirty = true;
return true;
}
#endif
if (_selected == DM_RESEND && p) {
int n = p->dm_resend_count;
if (right || enter) n = (n + 1) % 6; // 0..5, wraps
else if (left) n = (n + 5) % 6;
if (left || right || enter) { p->dm_resend_count = (uint8_t)n; _dirty = true; return true; }
}
if (_selected == BATT_DISPLAY && p) {
int idx = p->batt_display_mode < BATT_DISPLAY_COUNT ? p->batt_display_mode : 0;
if (right || enter) idx = (idx + 1) % BATT_DISPLAY_COUNT;
else if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT;
if (left || right || enter) { p->batt_display_mode = idx; _dirty = true; return true; }
}
#if FEAT_CLOCK_SECONDS_SETTING
if (_selected == CLOCK_SECONDS && p && (left || right || enter)) {
p->clock_hide_seconds ^= 1;
_dirty = true;
return true;
}
#endif
if (_selected == CLOCK_FORMAT && p && (left || right || enter)) {
p->clock_12h ^= 1;
_dirty = true;
return true;
}
#if FEAT_DISPLAY_ROTATION_SETTING
if (_selected == ROTATION && p && (left || right || enter)) {
p->display_rotation = (p->display_rotation + (left ? 3 : 1)) & 3;
_task->applyRotation();
_dirty = true;
return true;
}
#endif
#if FEAT_JOYSTICK_ROTATION_SETTING
if (_selected == JOY_ROTATION && p && (left || right || enter)) {
p->joystick_rotation = (p->joystick_rotation + (left ? 3 : 1)) & 3;
_dirty = true;
return true;
}
#endif
#if FEAT_FULL_REFRESH_SETTING
if (_selected == EINK_FULL_REFRESH && p && (left || right || enter)) {
int idx = p->eink_full_refresh_every;
if (idx >= EINK_FULL_REFRESH_COUNT) idx = 0;
idx = (idx + (left ? EINK_FULL_REFRESH_COUNT - 1 : 1)) % EINK_FULL_REFRESH_COUNT;
p->eink_full_refresh_every = idx;
_task->applyFullRefreshInterval();
_dirty = true;
return true;
}
#endif
if (_selected == DM_FILTER && p && (left || right || enter)) {
p->dm_show_all = p->dm_show_all ? 0 : 1;
_dirty = true;
return true;
}
if (_selected == CH_FILTER && p && (left || right || enter)) {
p->ch_fav_only = p->ch_fav_only ? 0 : 1;
_dirty = true;
return true;
}
if (_selected == ROOM_FILTER && p && (left || right || enter)) {
p->room_fav_only = p->room_fav_only ? 0 : 1;
_dirty = true;
return true;
}
if (_selected == FAV_SORT && p && (left || right || enter)) {
p->fav_sort_off = p->fav_sort_off ? 0 : 1;
_dirty = true;
return true;
}
if (_selected == EXPIRE_AFTER && p && (left || right || enter)) {
const int n_opt = NodePrefs::CONTACT_EXPIRY_COUNT;
int idx = (p->contact_expiry_idx < n_opt) ? p->contact_expiry_idx : 0;
idx = (idx + (left ? n_opt - 1 : 1)) % n_opt;
p->contact_expiry_idx = (uint8_t)idx;
_dirty = true;
return true;
}
if (_selected == PRUNE_NOW && enter) {
int n = the_mesh.countStaleContacts();
if (n == 0) {
// Two different "nothing happened" reasons, told apart so the row
// doesn't look broken when the threshold simply isn't set yet.
_task->showAlert(p && p->contact_expiry_idx == 0 ? "Expire is Off" : "No inactive contacts", 1400);
} else {
snprintf(_prune_confirm_title, sizeof(_prune_confirm_title), "Remove %d contact%s?", n, n == 1 ? "" : "s");
_prune_confirm.beginConfirm(_prune_confirm_title, "Remove");
}
return true;
}
if (isMsgSlot(_selected) && enter) {
int slot = msgSlotIndex(_selected);
_edit_slot = slot;
// Bound to the custom_msgs store (140 B) so the wider keyboard buffer
// can't overflow it on save.
_kb->begin(p ? p->custom_msgs[slot] : "",
p ? (int)sizeof(p->custom_msgs[slot]) - 1 : KB_MAX_LEN);
kbAddSensorPlaceholders(*_kb, &sensors);
return true;
}
return false;
}
};
#if AUTO_OFF_MILLIS > 0
const uint16_t SettingsScreen::AUTO_OFF_OPTS[5] = { 5, 15, 30, 60, 0 };
const char* SettingsScreen::AUTO_OFF_LABELS[5] = { "5s", "15s", "30s", "60s", "OFF" };
#endif
const uint16_t SettingsScreen::LOW_BAT_OPTS[7] = { 0, 3000, 3100, 3200, 3300, 3400, 3500 };
const char* SettingsScreen::LOW_BAT_LABELS[7] = { "OFF", "3.0V", "3.1V", "3.2V", "3.3V", "3.4V", "3.5V" };
#if ENV_INCLUDE_GPS == 1
const uint32_t SettingsScreen::GPS_DUTY_OPTS[6] = { 0, 60, 300, 900, 1800, 3600 };
const char* SettingsScreen::GPS_DUTY_LABELS[6] = { "OFF", "1 min", "5 min", "15 min", "30 min", "1 h" };
#endif
const char* SettingsScreen::BATT_DISPLAY_LABELS[3] = { "Icon", "%", "V" };
const char* SettingsScreen::SOUND_LABELS[4] = { "Built-in", "M1", "M2", "None" };
const char* SettingsScreen::AD_SCOPE_LABELS[2] = { "All", "Zero-hop" };
#if FEAT_FULL_REFRESH_SETTING
const char* SettingsScreen::EINK_FULL_REFRESH_LABELS[5] = { "OFF", "5", "10", "20", "30" };
#endif