From 8d3b955da1a8975256a7aecc5df45a94dc7cee87 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 14:46:53 +0200 Subject: [PATCH 01/28] feat: transliterate accented/diacritic characters to ASCII in translateUTF8ToBlocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the block-character fallback with proper transliteration for Polish (ą→a, ć→c, ę→e, ł→l, ń→n, ó→o, ś→s, ź→z, ż→z), German (ä ö ü ß), and common French/Spanish/Portuguese accents. Unknown codepoints fall back to '?' instead of █. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/DisplayDriver.h | 65 +++++++++++++++++++++++++++++----- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 7242091a..c15b0462 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -42,17 +42,64 @@ public: print(str); } - // convert UTF-8 characters to displayable block characters for compatibility + static char transliterateCodepoint(uint32_t cp) { + switch (cp) { + // Polish + case 0x0105: return 'a'; case 0x0104: return 'A'; // ą Ą + case 0x0107: return 'c'; case 0x0106: return 'C'; // ć Ć + case 0x0119: return 'e'; case 0x0118: return 'E'; // ę Ę + case 0x0142: return 'l'; case 0x0141: return 'L'; // ł Ł + case 0x0144: return 'n'; case 0x0143: return 'N'; // ń Ń + case 0x00F3: return 'o'; case 0x00D3: return 'O'; // ó Ó + case 0x015B: return 's'; case 0x015A: return 'S'; // ś Ś + case 0x017A: return 'z'; case 0x0179: return 'Z'; // ź Ź + case 0x017C: return 'z'; case 0x017B: return 'Z'; // ż Ż + // German + case 0x00E4: return 'a'; case 0x00C4: return 'A'; // ä Ä + case 0x00F6: return 'o'; case 0x00D6: return 'O'; // ö Ö + case 0x00FC: return 'u'; case 0x00DC: return 'U'; // ü Ü + case 0x00DF: return 's'; // ß + // French/Spanish/Portuguese common accents + case 0x00E0: case 0x00E1: case 0x00E2: case 0x00E3: return 'a'; + case 0x00C0: case 0x00C1: case 0x00C2: case 0x00C3: return 'A'; + case 0x00E8: case 0x00E9: case 0x00EA: case 0x00EB: return 'e'; + case 0x00C8: case 0x00C9: case 0x00CA: case 0x00CB: return 'E'; + case 0x00EC: case 0x00ED: case 0x00EE: case 0x00EF: return 'i'; + case 0x00CC: case 0x00CD: case 0x00CE: case 0x00CF: return 'I'; + case 0x00F2: case 0x00F4: case 0x00F5: return 'o'; + case 0x00D2: case 0x00D4: case 0x00D5: return 'O'; + case 0x00F9: case 0x00FA: case 0x00FB: return 'u'; + case 0x00D9: case 0x00DA: case 0x00DB: return 'U'; + case 0x00E7: return 'c'; case 0x00C7: return 'C'; // ç Ç + case 0x00F1: return 'n'; case 0x00D1: return 'N'; // ñ Ñ + case 0x00FD: return 'y'; case 0x00DD: return 'Y'; // ý Ý + default: return '?'; + } + } + + // convert UTF-8 to ASCII, transliterating accented/diacritic characters virtual void translateUTF8ToBlocks(char* dest, const char* src, size_t dest_size) { size_t j = 0; - for (size_t i = 0; src[i] != 0 && j < dest_size - 1; i++) { - unsigned char c = (unsigned char)src[i]; - if (c >= 32 && c <= 126) { - dest[j++] = c; // ASCII printable - } else if (c >= 0x80) { - dest[j++] = '\xDB'; // CP437 full block █ - while (src[i+1] && (src[i+1] & 0xC0) == 0x80) - i++; // skip UTF-8 continuation bytes + const uint8_t* p = (const uint8_t*)src; + while (*p && j < dest_size - 1) { + uint8_t c = *p++; + if (c < 0x80) { + if (c >= 32) dest[j++] = c; + } else { + uint32_t cp = c; + if ((c & 0xE0) == 0xC0) { + cp = c & 0x1F; + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + } else if ((c & 0xF0) == 0xE0) { + cp = c & 0x0F; + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + if (*p) cp = (cp << 6) | (*p++ & 0x3F); + } else { + while (*p && (*p & 0xC0) == 0x80) p++; + dest[j++] = '?'; + continue; + } + dest[j++] = transliterateCodepoint(cp); } } dest[j] = 0; From f6b13c5cabb593b625516a63e58b8c9b50b2e981 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 15:05:53 +0200 Subject: [PATCH 02/28] =?UTF-8?q?fix:=20use=20block=20char=20=E2=96=88=20f?= =?UTF-8?q?allback=20for=20unknown=20codepoints=20in=20transliteration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/DisplayDriver.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index c15b0462..1a512381 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -73,7 +73,7 @@ public: case 0x00E7: return 'c'; case 0x00C7: return 'C'; // ç Ç case 0x00F1: return 'n'; case 0x00D1: return 'N'; // ñ Ñ case 0x00FD: return 'y'; case 0x00DD: return 'Y'; // ý Ý - default: return '?'; + default: return '\xDB'; // CP437 full block █ } } @@ -96,7 +96,7 @@ public: if (*p) cp = (cp << 6) | (*p++ & 0x3F); } else { while (*p && (*p & 0xC0) == 0x80) p++; - dest[j++] = '?'; + dest[j++] = '\xDB'; continue; } dest[j++] = transliterateCodepoint(cp); From 31962726ec8476189d68c499a55e800219d3e2ec Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 15:11:12 +0200 Subject: [PATCH 03/28] fix: apply UTF-8 transliteration in drawTextEllipsized Names printed via drawTextEllipsized (contacts, senders) bypassed translateUTF8ToBlocks, causing raw UTF-8 bytes to render as garbage on the Adafruit font. Now transliteration happens inside the method. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/DisplayDriver.h | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 1a512381..229a907a 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -108,10 +108,7 @@ public: // draw text with ellipsis if it exceeds max_width virtual void drawTextEllipsized(int x, int y, int max_width, const char* str) { char temp_str[256]; // reasonable buffer size - size_t len = strlen(str); - if (len >= sizeof(temp_str)) len = sizeof(temp_str) - 1; - memcpy(temp_str, str, len); - temp_str[len] = 0; + translateUTF8ToBlocks(temp_str, str, sizeof(temp_str)); if (getTextWidth(temp_str) <= max_width) { setCursor(x, y); From 6a7f8b7ca7f89ea12d0d6028f425eadc7186699b Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 15:24:36 +0200 Subject: [PATCH 04/28] fix: transliterate message text in FullscreenMsgView before wrapping Raw UTF-8 message body was passed directly to display.print(), rendering Polish and other accented chars as garbage on the Adafruit font. Transliterating before wrapLines also fixes byte-count wrapping for multi-byte UTF-8 characters. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/FullscreenMsgView.h | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index d66d759e..5945a728 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -48,8 +48,10 @@ struct FullscreenMsgView { display.drawTextEllipsized(2, 1, display.width() - 4, sender); display.setColor(DisplayDriver::LIGHT); + char trans_text[512]; + display.translateUTF8ToBlocks(trans_text, text, sizeof(trans_text)); char lines[12][FS_CHARS + 1]; - int lcount = wrapLines(text, lines, 12); + int lcount = wrapLines(trans_text, lines, 12); int max_scroll = lcount > FS_VISIBLE ? lcount - FS_VISIBLE : 0; if (scroll > max_scroll) scroll = max_scroll; From b91afa05b9d3815ebb525f450c8368a96d3e6386 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 17:47:22 +0200 Subject: [PATCH 05/28] fix: reduce low battery shutdown text size to fit 128px OLED Size 2 rendered text wider than display width. Switched to size 1 and adjusted y-positions to keep both lines visible. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 7a34fdb2..c3316809 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -2844,9 +2844,9 @@ void UITask::loop() { if (low_mv > 0 && _batt_mv > 0 && _batt_mv < low_mv) { if (_display != NULL) { _display->startFrame(); - _display->setTextSize(2); + _display->setTextSize(1); _display->setColor(DisplayDriver::LIGHT); - _display->drawTextCentered(_display->width() / 2, 16, "Low Battery"); + _display->drawTextCentered(_display->width() / 2, 24, "Low Battery"); _display->drawTextCentered(_display->width() / 2, 36, "Shutting down"); _display->endFrame(); delay(2000); From 5bc250888f0b24fe11cddfe3a3c739b877db5031 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 23:15:29 +0200 Subject: [PATCH 06/28] fix: apply saved display brightness before UI task starts Loading... screen was always shown at full brightness because prefs are not yet loaded at that point. Apply setBrightness() in the first common code path after the_mesh.begin() loads prefs, so at least the tail of the loading screen and the entire splash screen respect the saved brightness level. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/main.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/companion_radio/main.cpp b/examples/companion_radio/main.cpp index 751de330..879d04be 100644 --- a/examples/companion_radio/main.cpp +++ b/examples/companion_radio/main.cpp @@ -219,6 +219,10 @@ void setup() { #endif #ifdef DISPLAY_CLASS + // Apply saved brightness as soon as prefs are available so the tail of + // the loading screen is not stuck at full brightness. + if (disp && the_mesh.getNodePrefs()) + disp->setBrightness(the_mesh.getNodePrefs()->display_brightness); ui_task.begin(disp, &sensors, the_mesh.getNodePrefs()); // still want to pass this in as dependency, as prefs might be moved #endif From 1b9dc42250f34b5f919e419ca8ae995a2dd2f13e Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 23:19:12 +0200 Subject: [PATCH 07/28] fix: bot trigger matched against message body only, not sender name Channel messages have the format "sender_name: body". The trigger was being searched across the whole string, so a nick containing the trigger word would fire a reply even with an unrelated message. Now the match skips past the ": " separator and only checks the message body. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMeshBot.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 673af4cf..0742bcd1 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -40,10 +40,15 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { millis() - _bot_last_reply_ms > 10000UL)) return; + // Skip "sender_name: " prefix so the trigger is only matched against the message body. + const char* msg = text; + const char* sep = strstr(text, ": "); + if (sep) msg = sep + 2; + const char* tr = _prefs.bot_trigger; int tlen = strlen(tr); bool matched = false; - for (const char* t = text; *t && !matched; t++) { + for (const char* t = msg; *t && !matched; t++) { int i = 0; while (i < tlen && tolower((uint8_t)t[i]) == tolower((uint8_t)tr[i])) i++; if (i == tlen) matched = true; From 44b2519df4ffccbfdee91f39c866b7028f705222 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 23:23:20 +0200 Subject: [PATCH 08/28] fix: with_sender buffer too small and FS_LINE_H mismatch - with_sender[160] could hold only ~160 chars but node_name(32) + expanded(200) needs up to 234; snprintf would silently truncate the UI display entry. Increased to 240. - FS_LINE_H was 9 but Adafruit 5x7 font line height is 8, wasting 1px per line and showing 5 lines instead of 6. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMeshBot.h | 2 +- examples/companion_radio/ui-new/FullscreenMsgView.h | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 0742bcd1..701ffeab 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -70,7 +70,7 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { _bot_last_reply_ms = millis(); #ifdef DISPLAY_CLASS if (_ui) { - char with_sender[160]; + char with_sender[240]; // node_name(32) + ": "(2) + expanded(200) + margin snprintf(with_sender, sizeof(with_sender), "%s: %s", _prefs.node_name, expanded); _ui->addChannelMsg(channel_idx, with_sender); } diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index 5945a728..1fa7bd0f 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -4,7 +4,7 @@ #include static const int FS_CHARS = 21; -static const int FS_LINE_H = 9; +static const int FS_LINE_H = 8; static const int FS_START_Y = 12; static const int FS_VISIBLE = (64 - FS_START_Y) / FS_LINE_H; From 5d41812784f5a13b90ba5f2fce4583528b863c45 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 23:26:31 +0200 Subject: [PATCH 09/28] fix: separate DM and channel bot reply cooldown timers Single shared _bot_last_reply_ms caused a DM reply to block the channel bot for 10s and vice versa. Split into _bot_last_dm_reply_ms and _bot_last_ch_reply_ms so each cooldown is independent. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMesh.cpp | 3 ++- examples/companion_radio/MyMesh.h | 3 ++- examples/companion_radio/MyMeshBot.h | 8 ++++---- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 79f52deb..1002fa5e 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -853,7 +853,8 @@ MyMesh::MyMesh(mesh::Radio &radio, mesh::RNG &rng, mesh::RTCClock &rtc, SimpleMe _cli_rescue = false; offline_queue_len = 0; app_target_ver = 0; - _bot_last_reply_ms = 0; + _bot_last_dm_reply_ms = 0; + _bot_last_ch_reply_ms = 0; _next_auto_advert_ms = 0; clearPendingReqs(); next_ack_idx = 0; diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index ec8a56b1..e6160a16 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -234,7 +234,8 @@ private: uint8_t *sign_data; uint32_t sign_data_len; unsigned long dirty_contacts_expiry; - unsigned long _bot_last_reply_ms; + unsigned long _bot_last_dm_reply_ms; + unsigned long _bot_last_ch_reply_ms; unsigned long _next_auto_advert_ms; TransportKey send_scope; diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 701ffeab..5c892164 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -5,7 +5,7 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { if (from.type != ADV_TYPE_CHAT) return; if (!(_prefs.bot_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_dm[0] && - millis() - _bot_last_reply_ms > 10000UL)) + millis() - _bot_last_dm_reply_ms > 10000UL)) return; const char* tr = _prefs.bot_trigger; @@ -27,7 +27,7 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { &sensors, (float)board.getBattMilliVolts() / 1000.0f); uint32_t expected_ack, est_timeout; if (sendMessage(from, ts, 0, expanded, expected_ack, est_timeout) != MSG_SEND_FAILED) { - _bot_last_reply_ms = millis(); + _bot_last_dm_reply_ms = millis(); #ifdef DISPLAY_CLASS if (_ui) _ui->addDMMsg(from.id.pub_key, true, expanded); #endif @@ -37,7 +37,7 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { if (!(_prefs.bot_channel_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_ch[0] && channel_idx == _prefs.bot_channel_idx && - millis() - _bot_last_reply_ms > 10000UL)) + millis() - _bot_last_ch_reply_ms > 10000UL)) return; // Skip "sender_name: " prefix so the trigger is only matched against the message body. @@ -67,7 +67,7 @@ void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { &sensors, (float)board.getBattMilliVolts() / 1000.0f); int rlen = strlen(expanded); if (sendGroupMessage(ts, ch.channel, _prefs.node_name, expanded, rlen)) { - _bot_last_reply_ms = millis(); + _bot_last_ch_reply_ms = millis(); #ifdef DISPLAY_CLASS if (_ui) { char with_sender[240]; // node_name(32) + ": "(2) + expanded(200) + margin From c4b152fd6aa3f440b9d90c5e8ffd03271716f9ef Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 23:28:18 +0200 Subject: [PATCH 10/28] fix: clear arrow background in FullscreenMsgView to prevent text overlap Text lines fill the full display width, causing the last chars to bleed into the scroll arrow area. Draw a DARK fillRect behind each arrow before rendering it, so the arrow appears on a clean black background without truncating the text width. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/FullscreenMsgView.h | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index 1fa7bd0f..99b3317c 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -60,10 +60,16 @@ struct FullscreenMsgView { display.print(lines[scroll + i]); } if (scroll > 0) { + display.setColor(DisplayDriver::DARK); + display.fillRect(display.width() - 6, FS_START_Y, 6, FS_LINE_H); + display.setColor(DisplayDriver::LIGHT); display.setCursor(display.width() - 6, FS_START_Y); display.print("^"); } if (scroll < max_scroll) { + display.setColor(DisplayDriver::DARK); + display.fillRect(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H, 6, FS_LINE_H); + display.setColor(DisplayDriver::LIGHT); display.setCursor(display.width() - 6, FS_START_Y + (FS_VISIBLE - 1) * FS_LINE_H); display.print("v"); } From a60ec376d7a5ecd8c370532e20058c86a7d3b47c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 16 May 2026 23:42:52 +0200 Subject: [PATCH 11/28] perf: increase render interval on static screens from 300ms to 2000ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static screens (SettingsScreen, BotScreen, FullscreenMsgView, message list, contact picker) were re-rendering every 300ms with no visible benefit. New messages still trigger immediate refresh via newMsg() → _next_refresh=100, so responsiveness is unchanged. Reduces unnecessary SPI transfers and CPU wakeups ~6x on idle static screens. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/BotScreen.h | 2 +- examples/companion_radio/ui-new/FullscreenMsgView.h | 2 +- examples/companion_radio/ui-new/UITask.cpp | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/examples/companion_radio/ui-new/BotScreen.h b/examples/companion_radio/ui-new/BotScreen.h index 5a333af6..cf0b0ec8 100644 --- a/examples/companion_radio/ui-new/BotScreen.h +++ b/examples/companion_radio/ui-new/BotScreen.h @@ -97,7 +97,7 @@ public: } display.setColor(DisplayDriver::LIGHT); } - return 300; + return 2000; } bool handleInput(char c) override { diff --git a/examples/companion_radio/ui-new/FullscreenMsgView.h b/examples/companion_radio/ui-new/FullscreenMsgView.h index 99b3317c..a5bfe99f 100644 --- a/examples/companion_radio/ui-new/FullscreenMsgView.h +++ b/examples/companion_radio/ui-new/FullscreenMsgView.h @@ -81,7 +81,7 @@ struct FullscreenMsgView { display.setCursor(display.width() - 6, 56); display.print(">"); } - return 300; + return 2000; } Result handleInput(char c) { diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index c3316809..ed17df45 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -478,7 +478,7 @@ public: display.print("v"); } - return 300; + return 2000; } bool handleInput(char c) override { @@ -1293,7 +1293,7 @@ public: _hist_sel < fs_hist_count - 1, _hist_sel > 0); } - return 300; + return 2000; } ChannelDetails ch; @@ -1421,7 +1421,7 @@ public: display.setColor(DisplayDriver::LIGHT); renderScrollHints(display, _msg_scroll, total_msg_items); } - return 300; + return 2000; } bool handleInput(char c) override { From b068318bc5d6e5ec7610ee4442275f8691bb51bf Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 00:12:40 +0200 Subject: [PATCH 12/28] refactor: extract SettingsScreen and QuickMsgScreen to separate header files Co-Authored-By: Claude Sonnet 4.6 --- .../companion_radio/ui-new/QuickMsgScreen.h | 1071 +++++++++++ .../companion_radio/ui-new/SettingsScreen.h | 518 ++++++ examples/companion_radio/ui-new/UITask.cpp | 1585 +---------------- 3 files changed, 1591 insertions(+), 1583 deletions(-) create mode 100644 examples/companion_radio/ui-new/QuickMsgScreen.h create mode 100644 examples/companion_radio/ui-new/SettingsScreen.h diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h new file mode 100644 index 00000000..a5b4b2ba --- /dev/null +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -0,0 +1,1071 @@ +#pragma once +// Custom screen — not part of upstream UITask.cpp +// Included by UITask.cpp after SettingsScreen.h is defined. + +class QuickMsgScreen : public UIScreen { + UITask* _task; + + enum Phase { MODE_SELECT, CONTACT_PICK, DM_HIST, MSG_PICK, CHANNEL_PICK, CHANNEL_HIST, KEYBOARD }; + Phase _phase; + + // MODE_SELECT + int _mode_sel; // 0=Direct, 1=Channel, 2=Room Servers + + // CONTACT_PICK + int _contact_sel, _contact_scroll; + int _num_contacts; + uint16_t _sorted[MAX_CONTACTS]; + ContactInfo _sel_contact; + bool _room_mode; // true = picking a room server, false = picking a DM contact + + // CHANNEL_PICK + int _channel_sel, _channel_scroll; + int _num_channels; + uint8_t _channel_indices[MAX_GROUP_CHANNELS]; + uint8_t _ch_unread[MAX_GROUP_CHANNELS]; + int _sel_channel_idx; + bool _sending_to_channel; + + // MSG_PICK (shared) + int _msg_sel, _msg_scroll; + int _active_msgs[QUICK_MSGS_MAX]; + int _active_msg_count; + + // CHANNEL_HIST + int _hist_sel, _hist_scroll; + FullscreenMsgView _fs; + int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST + int _viewing_max_seen; // highest _hist_sel reached in current session + static const int CH_HIST_MAX = 32; + + // KEYBOARD + KeyboardWidget _kb; + + // Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK) + PopupMenu _ctx_menu; + bool _ctx_dirty; + char _ctx_notif_item[22]; + char _ctx_melody_item[20]; + + struct ChHistEntry { uint8_t ch_idx; char text[140]; }; + ChHistEntry _hist[CH_HIST_MAX]; + int _hist_head, _hist_count; + + // DM_HIST + struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[80]; }; + static const int DM_HIST_MAX = 64; + DmHistEntry _dm_hist[DM_HIST_MAX]; + int _dm_hist_head, _dm_hist_count; + int _dm_hist_sel, _dm_hist_scroll; + FullscreenMsgView _dm_fs; + + static const int VISIBLE = 4; + static const int ITEM_H = 12; + static const int START_Y = 12; + static const int HIST_VISIBLE = 2; // 2-line boxed history entries + static const int HIST_ITEM_H = 21; // box(19) + gap(2) + static const int HIST_BOX_H = 19; + static const int HIST_START_Y = 11; + + void expandMsg(const char* tmpl, char* out, int out_len) const { + double lat = 0, lon = 0; + bool gps_valid = false; +#if ENV_INCLUDE_GPS == 1 + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) { + lat = loc->getLatitude() / 1000000.0; + lon = loc->getLongitude() / 1000000.0; + gps_valid = true; + } +#endif + NodePrefs* np = _task->getNodePrefs(); + float batt = (float)board.getBattMilliVolts() / 1000.0f; + ::expandMsg(tmpl, out, out_len, lat, lon, gps_valid, + rtc_clock.getCurrentTime(), + np ? np->tz_offset_hours : 0, + &sensors, batt); + } + + void setupMsgPick() { + _msg_sel = _msg_scroll = 0; + _active_msg_count = 0; + NodePrefs* p = _task->getNodePrefs(); + if (p) { + for (int i = 0; i < QUICK_MSGS_MAX; i++) { + if (p->custom_msgs[i][0] != '\0') + _active_msgs[_active_msg_count++] = i; + } + } + } + + void renderScrollHints(DisplayDriver& display, int scroll, int count) { + display.setColor(DisplayDriver::LIGHT); + if (scroll > 0) { + display.setCursor(display.width() - 6, START_Y); + display.print("^"); + } + if (scroll + VISIBLE < count) { + display.setCursor(display.width() - 6, START_Y + (VISIBLE-1)*ITEM_H); + display.print("v"); + } + } + + // count history entries for a specific channel + int histCountForChannel(int ch_idx) const { + int n = 0; + for (int i = 0; i < _hist_count; i++) { + if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; + } + return n; + } + + // get ring-buffer position of j-th history entry for channel (newest first) + int histEntryForChannel(int ch_idx, int j) const { + int n = 0; + for (int i = _hist_count - 1; i >= 0; i--) { + int pos = (_hist_head + i) % CH_HIST_MAX; + if (_hist[pos].ch_idx == (uint8_t)ch_idx) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { + int pos; + if (_dm_hist_count < DM_HIST_MAX) { + pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; + _dm_hist_count++; + } else { + pos = _dm_hist_head; + _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; + } + memcpy(_dm_hist[pos].prefix, pub_key, 4); + _dm_hist[pos].outgoing = outgoing ? 1 : 0; + strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); + _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; + } + + int dmHistCountForContact(const uint8_t* prefix) const { + int n = 0; + for (int i = 0; i < _dm_hist_count; i++) + if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; + return n; + } + + int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest + int n = 0; + for (int i = _dm_hist_count - 1; i >= 0; i--) { + int pos = (_dm_hist_head + i) % DM_HIST_MAX; + if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { + if (n == j) return pos; + n++; + } + } + return -1; + } + + void afterSend(bool ok, const char* msg) { + if (ok && _sending_to_channel) { + _hist_sel = 0; + _hist_scroll = 0; + _phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump + char entry[sizeof(ChHistEntry::text)]; + snprintf(entry, sizeof(entry), "Me: %s", msg); + addChannelMsg(_sel_channel_idx, entry); + // After inserting sent msg at index 0, the unread index range is stale. + // User is active in this channel — treat as fully read. + _ch_unread[_sel_channel_idx] = 0; + _unread_at_entry = 0; + _viewing_max_seen = 0; + _task->showAlert("Sent!", 600); + } else if (ok) { + storeDMMsg(_sel_contact.id.pub_key, true, msg); + _dm_hist_sel = 0; + _dm_hist_scroll = 0; + _phase = DM_HIST; + _task->showAlert("Sent!", 600); + } else { + _task->showAlert("Send failed", 1500); + _task->gotoHomeScreen(); + } + } + + bool sendText(const char* msg) { + if (_sending_to_channel) { + ChannelDetails ch; + if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false; + return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel, + the_mesh.getNodeName(), msg, strlen(msg)); + } else { + uint32_t expected_ack = 0, est_timeout = 0; + return the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0, + msg, expected_ack, est_timeout) > 0; + } + } + + void buildContactList() { + NodePrefs* p = _task->getNodePrefs(); + ContactInfo c; + int total = the_mesh.getNumContacts(); + _num_contacts = 0; + if (_room_mode) { + bool fav_only = (p && p->room_fav_only); + for (int i = 0; i < total; i++) { + if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_ROOM) continue; + if (fav_only && !(c.flags & 0x01)) continue; + _sorted[_num_contacts++] = i; + } + } else { + bool show_all = (p && p->dm_show_all); + for (int i = 0; i < total; i++) { + if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; + if (!show_all && !(c.flags & 0x01)) continue; + _sorted[_num_contacts++] = i; + } + } + } + + void buildChannelList() { + _num_channels = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { + _channel_indices[_num_channels++] = (uint8_t)i; + } + } + } + + // Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on + uint8_t chNotifState(uint8_t ch_idx) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + uint64_t mask = 1ULL << ch_idx; + if (!(p->ch_notif_override & mask)) return 0; + return (p->ch_notif_muted & mask) ? 1 : 2; + } + + void setChNotifState(uint8_t ch_idx, uint8_t state) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + uint64_t mask = 1ULL << ch_idx; + if (state == 0) { + p->ch_notif_override &= ~mask; + p->ch_notif_muted &= ~mask; + } else if (state == 1) { + p->ch_notif_override |= mask; + p->ch_notif_muted |= mask; + } else { + p->ch_notif_override |= mask; + p->ch_notif_muted &= ~mask; + } + } + + uint8_t dmNotifState(const uint8_t* pub_key) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) + if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) + return p->dm_notif[i].state; + return 0; + } + + void setDmNotifState(const uint8_t* pub_key, uint8_t state) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) { + if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); } + else p->dm_notif[i].state = state; + return; + } + } + if (state == 0) return; + for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { + if (p->dm_notif[i].state == 0) { + memcpy(p->dm_notif[i].prefix, pub_key, 4); + p->dm_notif[i].state = state; + return; + } + } + // table full — overwrite slot 0 + memcpy(p->dm_notif[0].prefix, pub_key, 4); + p->dm_notif[0].state = state; + } + + uint8_t chNotifMelody(uint8_t ch_idx) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + uint64_t mask = 1ULL << ch_idx; + if (!(p->ch_notif_melody_set & mask)) return 0; + return (p->ch_notif_melody_2 & mask) ? 2 : 1; + } + + void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + uint64_t mask = 1ULL << ch_idx; + if (slot == 0) { + p->ch_notif_melody_set &= ~mask; + p->ch_notif_melody_2 &= ~mask; + } else if (slot == 1) { + p->ch_notif_melody_set |= mask; + p->ch_notif_melody_2 &= ~mask; + } else { + p->ch_notif_melody_set |= mask; + p->ch_notif_melody_2 |= mask; + } + } + + uint8_t dmMelodySlot(const uint8_t* pub_key) const { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) + if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) + return p->dm_melody[i].slot; + return 0; + } + + void setDmMelody(const uint8_t* pub_key, uint8_t slot) { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return; + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { + if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) { + if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i])); + else p->dm_melody[i].slot = slot; + return; + } + } + if (slot == 0) return; + for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { + if (p->dm_melody[i].slot == 0) { + memcpy(p->dm_melody[i].prefix, pub_key, 4); + p->dm_melody[i].slot = slot; + return; + } + } + memcpy(p->dm_melody[0].prefix, pub_key, 4); + p->dm_melody[0].slot = slot; + } + +public: + QuickMsgScreen(UITask* task) + : _task(task), _phase(MODE_SELECT), _mode_sel(0), + _contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), + _channel_sel(0), _channel_scroll(0), _num_channels(0), + _sel_channel_idx(0), _sending_to_channel(false), + _msg_sel(0), _msg_scroll(0), _active_msg_count(0), + _hist_sel(0), _hist_scroll(0), + _unread_at_entry(0), _viewing_max_seen(0), + _hist_head(0), _hist_count(0), + _dm_hist_head(0), _dm_hist_count(0), + _dm_hist_sel(-1), _dm_hist_scroll(0), + _ctx_dirty(false) { + memset(_ch_unread, 0, sizeof(_ch_unread)); + } + + void addChannelMsg(uint8_t ch_idx, const char* text) { + int pos; + if (_hist_count < CH_HIST_MAX) { + pos = (_hist_head + _hist_count) % CH_HIST_MAX; + _hist_count++; + } else { + pos = _hist_head; + _hist_head = (_hist_head + 1) % CH_HIST_MAX; + } + _hist[pos].ch_idx = ch_idx; + strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); + _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; + + if (ch_idx < MAX_GROUP_CHANNELS) { + bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); + if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; + } + } + + void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { + storeDMMsg(pub_key, outgoing, text); + } + + int getDMUnreadTotal() const { + return _task->getDMUnreadTotal(); + } + + int getTotalChannelUnread() const { + int total = 0; + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; + return total; + } + + void clearAllChannelUnread() { + memset(_ch_unread, 0, sizeof(_ch_unread)); + } + + void updateChannelUnread() { + if (_hist_sel < 0 || _sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; + if (_hist_sel > _viewing_max_seen) _viewing_max_seen = _hist_sel; + // histEntryForChannel is newest-first: index 0 = newest (unread), higher = older. + // Each step down from 0 sees one more message; seen count = max_seen + 1. + int remaining = _unread_at_entry - (_viewing_max_seen + 1); + _ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0); + } + + void reset() { + _phase = MODE_SELECT; + _mode_sel = 0; + _contact_sel = _contact_scroll = 0; + _msg_sel = _msg_scroll = 0; + _channel_sel = _channel_scroll = 0; + _sending_to_channel = false; + + _room_mode = false; + buildContactList(); + buildChannelList(); + + _ctx_menu.active = false; + _ctx_dirty = false; + _unread_at_entry = 0; + _viewing_max_seen = 0; + } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + display.setColor(DisplayDriver::LIGHT); + + if (_phase == MODE_SELECT) { + display.drawTextCentered(display.width()/2, 0, "MESSAGE"); + display.fillRect(0, 10, display.width(), 1); + const char* opts[] = { "Direct message", "Channels", "Room Servers" }; + int badges[3] = { + getDMUnreadTotal(), + _task->getChannelUnreadCount(), + _task->getRoomUnreadCount() + }; + for (int i = 0; i < 3; i++) { + int y = START_Y + i * ITEM_H; + bool sel = (i == _mode_sel); + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + display.setCursor(8, y); + display.print(opts[i]); + if (badges[i] > 0) { + char badge[5]; + snprintf(badge, sizeof(badge), "%d", badges[i]); + int bw = display.getTextWidth(badge) + 2; + display.setCursor(display.width() - bw, y); + display.print(badge); + } + } + display.setColor(DisplayDriver::LIGHT); + + } else if (_phase == CONTACT_PICK) { + display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT"); + display.fillRect(0, 10, display.width(), 1); + + if (_num_contacts == 0) { + display.drawTextCentered(display.width()/2, 32, _room_mode ? "No room servers" : "No favourites"); + return 5000; + } + + for (int i = 0; i < VISIBLE && (_contact_scroll+i) < _num_contacts; i++) { + int list_idx = _contact_scroll + i; + int mesh_idx = _sorted[list_idx]; + bool sel = (list_idx == _contact_sel); + int y = START_Y + i * ITEM_H; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + ContactInfo c; + if (the_mesh.getContactByIdx(mesh_idx, c)) { + display.setCursor(0, y); + display.print(sel ? ">" : " "); + char filtered[sizeof(c.name)]; + display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); + uint8_t dm_unread = _task->getDMUnread(c.id.pub_key); + char badge[5] = ""; + int bw = 0; + if (dm_unread > 0) { + snprintf(badge, sizeof(badge), "%d", (int)dm_unread); + bw = display.getTextWidth(badge) + 2; + } + display.drawTextEllipsized(8, y, display.width() - 8 - bw - 1, filtered); + if (dm_unread > 0) { + display.setCursor(display.width() - bw + 1, y); + display.print(badge); + } + } + } + display.setColor(DisplayDriver::LIGHT); + renderScrollHints(display, _contact_scroll, _num_contacts); + + // Context menu overlay + if (_ctx_menu.active) _ctx_menu.render(display); + + } else if (_phase == CHANNEL_PICK) { + display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL"); + display.fillRect(0, 10, display.width(), 1); + + if (_num_channels == 0) { + display.drawTextCentered(display.width()/2, 32, "No channels"); + return 5000; + } + + for (int i = 0; i < VISIBLE && (_channel_scroll+i) < _num_channels; i++) { + int list_idx = _channel_scroll + i; + bool sel = (list_idx == _channel_sel); + int y = START_Y + i * ITEM_H; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + ChannelDetails ch; + if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { + uint8_t unread = _ch_unread[_channel_indices[list_idx]]; + char badge[5] = ""; + int bw = 0; + if (unread > 0) { + snprintf(badge, sizeof(badge), "%d", (int)unread); + bw = display.getTextWidth(badge) + 2; + } + display.drawTextEllipsized(8, y, display.width() - 10 - bw, ch.name); + if (unread > 0) { + display.setCursor(display.width() - bw, y); + display.print(badge); + } + } + } + display.setColor(DisplayDriver::LIGHT); + renderScrollHints(display, _channel_scroll, _num_channels); + + // Context menu overlay + if (_ctx_menu.active) _ctx_menu.render(display); + + } else if (_phase == DM_HIST) { + display.setTextSize(1); + char filtered_name[sizeof(_sel_contact.name)]; + display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); + + if (_dm_fs.active && _dm_hist_sel >= 0) { + int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); + if (ring_pos >= 0) { + const DmHistEntry& e = _dm_hist[ring_pos]; + const char* sender = e.outgoing ? "Me" : filtered_name; + int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + return _dm_fs.render(display, sender, e.text, + _dm_hist_sel < dm_count - 1, + _dm_hist_sel > 0); + } + return 500; + } + + char title[24]; + display.setColor(DisplayDriver::LIGHT); + snprintf(title, sizeof(title), "%.23s", filtered_name); + display.drawTextCentered(display.width()/2, 0, title); + display.fillRect(0, 9, display.width(), 1); + + int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + + for (int i = 0; i < HIST_VISIBLE && (_dm_hist_scroll + i) < dm_count; i++) { + int item = _dm_hist_scroll + i; + bool sel = (item == _dm_hist_sel); + int y = HIST_START_Y + i * HIST_ITEM_H; + + int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item); + if (ring_pos < 0) continue; + + const DmHistEntry& e = _dm_hist[ring_pos]; + const char* sender = e.outgoing ? "Me" : filtered_name; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y, display.width(), HIST_BOX_H); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, y, display.width(), HIST_BOX_H); + display.fillRect(1, y + 1, display.width() - 2, 8); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.setColor(DisplayDriver::LIGHT); + display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); + } + } + + if (dm_count == 0) { + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width()/2, 32, "No messages yet"); + } + + display.setColor(DisplayDriver::LIGHT); + if (_dm_hist_scroll > 0) { + display.setCursor(display.width() - 6, HIST_START_Y + 1); + display.print("^"); + } + if (_dm_hist_scroll + HIST_VISIBLE < dm_count) { + display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); + display.print("v"); + } + + bool compose_sel = (_dm_hist_sel == -1); + const char* ctxt = "[+ send]"; + int ctw = display.getTextWidth(ctxt); + int cbx = 1, cby = 55; + if (compose_sel) { + display.fillRect(cbx, cby - 1, ctw + 4, 9); + display.setColor(DisplayDriver::DARK); + } + display.setCursor(cbx + 2, cby); + display.print(ctxt); + display.setColor(DisplayDriver::LIGHT); + return dm_count > 0 ? 500 : 2000; + + } else if (_phase == CHANNEL_HIST) { + if (_fs.active && _hist_sel >= 0) { + int fs_hist_count = histCountForChannel(_sel_channel_idx); + int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); + if (ring_pos >= 0) { + const char* ftext = _hist[ring_pos].text; + const char* fsep = strstr(ftext, ": "); + char fsender[22], fmsg[140]; + if (fsep) { + int nl = fsep - ftext; if (nl > 21) nl = 21; + strncpy(fsender, ftext, nl); fsender[nl] = '\0'; + strncpy(fmsg, fsep + 2, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; + } else { + strncpy(fsender, "?", sizeof(fsender)); + strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; + } + return _fs.render(display, fsender, fmsg, + _hist_sel < fs_hist_count - 1, + _hist_sel > 0); + } + return 2000; + } + + ChannelDetails ch; + the_mesh.getChannel(_sel_channel_idx, ch); + char title[24]; + snprintf(title, sizeof(title), "%.23s", ch.name); + display.drawTextCentered(display.width()/2, 0, title); + display.fillRect(0, 9, display.width(), 1); + + int ch_hist_count = histCountForChannel(_sel_channel_idx); + + for (int i = 0; i < HIST_VISIBLE && (_hist_scroll + i) < ch_hist_count; i++) { + int item = _hist_scroll + i; + bool sel = (item == _hist_sel); + int y = HIST_START_Y + i * HIST_ITEM_H; + + int ring_pos = histEntryForChannel(_sel_channel_idx, item); + if (ring_pos < 0) continue; + + const char* text = _hist[ring_pos].text; + const char* sep = strstr(text, ": "); + char sender[22], msg_part[79]; + if (sep) { + int nl = sep - text; if (nl > 21) nl = 21; + strncpy(sender, text, nl); sender[nl] = '\0'; + strncpy(msg_part, sep + 2, sizeof(msg_part) - 1); + msg_part[sizeof(msg_part) - 1] = '\0'; + } else { + strncpy(sender, "?", sizeof(sender)); + strncpy(msg_part, text, sizeof(msg_part) - 1); + msg_part[sizeof(msg_part) - 1] = '\0'; + } + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y, display.width(), HIST_BOX_H); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(0, y, display.width(), HIST_BOX_H); + display.fillRect(1, y + 1, display.width() - 2, 8); + display.setColor(DisplayDriver::DARK); + display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); + display.setColor(DisplayDriver::LIGHT); + display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); + } + } + + if (ch_hist_count == 0) { + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width()/2, 32, "No messages yet"); + } + + // scroll hints + display.setColor(DisplayDriver::LIGHT); + if (_hist_scroll > 0) { + display.setCursor(display.width() - 6, HIST_START_Y + 1); + display.print("^"); + } + if (_hist_scroll + HIST_VISIBLE < ch_hist_count) { + display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); + display.print("v"); + } + + // small compose button (bottom-left, always bordered, inverted when selected) + bool compose_sel = (_hist_sel == -1); + const char* ctxt = "[+ send]"; + int ctw = display.getTextWidth(ctxt); + int cbx = 1, cby = 55; + if (compose_sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(cbx - 1, cby - 1, ctw + 4, 10); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + display.drawRect(cbx - 1, cby - 1, ctw + 4, 10); + } + display.setCursor(cbx + 1, cby); + display.print(ctxt); + display.setColor(DisplayDriver::LIGHT); + + } else if (_phase == KEYBOARD) { + return _kb.render(display); + + } else { // MSG_PICK + char title[24]; + if (_sending_to_channel) { + ChannelDetails ch; + the_mesh.getChannel(_sel_channel_idx, ch); + snprintf(title, sizeof(title), "%.23s", ch.name); + } else { + snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name); + } + display.drawTextCentered(display.width()/2, 0, title); + display.fillRect(0, 10, display.width(), 1); + + int total_msg_items = 1 + _active_msg_count; + for (int i = 0; i < VISIBLE && (_msg_scroll+i) < total_msg_items; i++) { + int idx = _msg_scroll + i; + bool sel = (idx == _msg_sel); + int y = START_Y + i * ITEM_H; + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H - 1); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + display.setCursor(0, y); + display.print(sel ? ">" : " "); + + if (idx == 0) { + display.setCursor(8, y); + display.print("Custom message..."); + } else { + NodePrefs* p = _task->getNodePrefs(); + int slot = _active_msgs[idx - 1]; + const char* tmpl = p ? p->custom_msgs[slot] : ""; + display.drawTextEllipsized(8, y, display.width() - 10, tmpl); + } + } + display.setColor(DisplayDriver::LIGHT); + renderScrollHints(display, _msg_scroll, total_msg_items); + } + return 2000; + } + + bool handleInput(char c) override { + if (_phase == MODE_SELECT) { + if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; } + if (c == KEY_UP && _mode_sel > 0) { _mode_sel--; return true; } + if (c == KEY_DOWN && _mode_sel < 2) { _mode_sel++; return true; } + if (c == KEY_ENTER) { + if (_mode_sel == 1) { + _phase = CHANNEL_PICK; + } else { + _room_mode = (_mode_sel == 2); + if (_room_mode) _task->clearRoomUnread(); + buildContactList(); + _contact_sel = _contact_scroll = 0; + _phase = CONTACT_PICK; + } + return true; + } + + } else if (_phase == CONTACT_PICK) { + // Context menu consumes all input while open + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED && _num_contacts > 0) { + ContactInfo ci; + if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { + if (_ctx_menu.selectedIndex() == 0) { + _task->clearDMUnread(ci.id.pub_key); + } else if (_ctx_menu.selectedIndex() == 1) { + uint8_t nstate = dmNotifState(ci.id.pub_key); + setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); + _ctx_dirty = true; + } else { + uint8_t slot = dmMelodySlot(ci.id.pub_key); + setDmMelody(ci.id.pub_key, (slot + 1) % 3); + _ctx_dirty = true; + } + } + } + if (res != PopupMenu::NONE && _ctx_dirty) { + the_mesh.savePrefs(); _ctx_dirty = false; + } + return true; + } + if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; } + if (c == KEY_UP && _contact_sel > 0) { + _contact_sel--; + if (_contact_sel < _contact_scroll) _contact_scroll = _contact_sel; + return true; + } + if (c == KEY_DOWN && _contact_sel < _num_contacts - 1) { + _contact_sel++; + if (_contact_sel >= _contact_scroll + VISIBLE) _contact_scroll = _contact_sel - VISIBLE + 1; + return true; + } + if (c == KEY_ENTER && _num_contacts > 0) { + if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { + _task->clearDMUnread(_sel_contact.id.pub_key); + _dm_hist_sel = -1; + _dm_hist_scroll = 0; + _dm_fs.active = false; + _phase = DM_HIST; + } + return true; + } + if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + ContactInfo ci; + the_mesh.getContactByIdx(_sorted[_contact_sel], ci); + snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", + NOTIF_LABELS[dmNotifState(ci.id.pub_key)]); + { static const char* ML[] = { "global", "M1", "M2" }; + snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", + ML[dmMelodySlot(ci.id.pub_key)]); } + _ctx_menu.begin("Contact options", 3); + _ctx_menu.addItem("Mark as read"); + _ctx_menu.addItem(_ctx_notif_item); + _ctx_menu.addItem(_ctx_melody_item); + _ctx_dirty = false; + return true; + } + + } else if (_phase == CHANNEL_PICK) { + // Context menu consumes all input while open + if (_ctx_menu.active) { + auto res = _ctx_menu.handleInput(c); + if (res == PopupMenu::SELECTED && _num_channels > 0) { + uint8_t ch_idx = _channel_indices[_channel_sel]; + if (_ctx_menu.selectedIndex() == 0) { + _ch_unread[ch_idx] = 0; + } else if (_ctx_menu.selectedIndex() == 1) { + uint8_t nstate = chNotifState(ch_idx); + setChNotifState(ch_idx, (nstate + 1) % 3); + _ctx_dirty = true; + } else { + uint8_t slot = chNotifMelody(ch_idx); + setChNotifMelody(ch_idx, (slot + 1) % 3); + _ctx_dirty = true; + } + } + if (res != PopupMenu::NONE && _ctx_dirty) { + the_mesh.savePrefs(); _ctx_dirty = false; + } + return true; + } + if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } + if (c == KEY_UP && _channel_sel > 0) { + _channel_sel--; + if (_channel_sel < _channel_scroll) _channel_scroll = _channel_sel; + return true; + } + if (c == KEY_DOWN && _channel_sel < _num_channels - 1) { + _channel_sel++; + if (_channel_sel >= _channel_scroll + VISIBLE) _channel_scroll = _channel_sel - VISIBLE + 1; + return true; + } + if (c == KEY_ENTER && _num_channels > 0) { + _sel_channel_idx = _channel_indices[_channel_sel]; + int hc = histCountForChannel(_sel_channel_idx); + _unread_at_entry = (int)_ch_unread[_sel_channel_idx]; + _hist_scroll = 0; + _hist_sel = hc > 0 ? 0 : -1; + _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; + _phase = CHANNEL_HIST; + updateChannelUnread(); + return true; + } + if (c == KEY_CONTEXT_MENU && _num_channels > 0) { + uint8_t ch_idx = _channel_indices[_channel_sel]; + static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; + snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", + NOTIF_LABELS[chNotifState(ch_idx)]); + { static const char* ML[] = { "global", "M1", "M2" }; + snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", + ML[chNotifMelody(ch_idx)]); } + _ctx_menu.begin("Channel options", 3); + _ctx_menu.addItem("Mark all read"); + _ctx_menu.addItem(_ctx_notif_item); + _ctx_menu.addItem(_ctx_melody_item); + _ctx_dirty = false; + return true; + } + + } else if (_phase == DM_HIST) { + int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); + if (_dm_fs.active) { + auto res = _dm_fs.handleInput(c); + if (res == FullscreenMsgView::PREV) { + if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; } + } else if (res == FullscreenMsgView::NEXT) { + if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; } + } else if (res == FullscreenMsgView::CLOSE) { + _dm_fs.active = false; + } + return true; + } + if (c == KEY_CANCEL) { _phase = CONTACT_PICK; return true; } + if (c == KEY_UP) { + if (_dm_hist_sel > 0) { + _dm_hist_sel--; + if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel; + } else if (_dm_hist_sel == 0) { + _dm_hist_sel = -1; + } + return true; + } + if (c == KEY_DOWN) { + if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; } + else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) { + _dm_hist_sel++; + if (_dm_hist_sel >= _dm_hist_scroll + HIST_VISIBLE) + _dm_hist_scroll = _dm_hist_sel - HIST_VISIBLE + 1; + } + return true; + } + if (c == KEY_ENTER) { + if (_dm_hist_sel >= 0) { + _dm_fs.begin(); + } else { + _sending_to_channel = false; + setupMsgPick(); + _phase = MSG_PICK; + } + return true; + } + + } else if (_phase == CHANNEL_HIST) { + int ch_hist_count = histCountForChannel(_sel_channel_idx); + if (_fs.active) { + auto res = _fs.handleInput(c); + if (res == FullscreenMsgView::PREV) { + if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); } + } else if (res == FullscreenMsgView::NEXT) { + if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); } + } else if (res == FullscreenMsgView::CLOSE) { + _fs.active = false; + } + return true; + } + if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; } + if (c == KEY_UP) { + if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; } + else if (_hist_sel == 0) _hist_sel = -1; + updateChannelUnread(); + return true; + } + if (c == KEY_DOWN) { + if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; } + else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) { + _hist_sel++; + if (_hist_sel >= _hist_scroll + HIST_VISIBLE) _hist_scroll = _hist_sel - HIST_VISIBLE + 1; + } + updateChannelUnread(); + return true; + } + if (c == KEY_ENTER) { + if (_hist_sel >= 0) { + _fs.begin(); + updateChannelUnread(); + } else { + _sending_to_channel = true; + setupMsgPick(); + _phase = MSG_PICK; + } + return true; + } + + } else if (_phase == KEYBOARD) { + auto res = _kb.handleInput(c); + if (res == KeyboardWidget::CANCELLED) { + _phase = MSG_PICK; + } else if (res == KeyboardWidget::DONE) { + if (_kb.len > 0) { + char expanded[KB_MAX_LEN + 1]; + expandMsg(_kb.buf, expanded, sizeof(expanded)); + bool ok = sendText(expanded); + afterSend(ok, expanded); + } + } + return true; + + } else { // MSG_PICK + int total_msg_items = 1 + _active_msg_count; + if (c == KEY_CANCEL) { + _phase = _sending_to_channel ? CHANNEL_HIST : DM_HIST; + return true; + } + if (c == KEY_UP && _msg_sel > 0) { + _msg_sel--; + if (_msg_sel < _msg_scroll) _msg_scroll = _msg_sel; + return true; + } + if (c == KEY_DOWN && _msg_sel < total_msg_items - 1) { + _msg_sel++; + if (_msg_sel >= _msg_scroll + VISIBLE) _msg_scroll = _msg_sel - VISIBLE + 1; + return true; + } + if (c == KEY_ENTER) { + if (_msg_sel == 0) { + _kb.begin(); + kbAddSensorPlaceholders(_kb, &sensors); + _phase = KEYBOARD; + return true; + } + NodePrefs* p = _task->getNodePrefs(); + int slot = _active_msgs[_msg_sel - 1]; + const char* tmpl = p ? p->custom_msgs[slot] : "OK"; + char msg[80]; + expandMsg(tmpl, msg, sizeof(msg)); + bool ok = sendText(msg); + afterSend(ok, msg); + return true; + } + } + return false; + } +}; diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h new file mode 100644 index 00000000..e5e8c6ed --- /dev/null +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -0,0 +1,518 @@ +#pragma once +// Custom screen — not part of upstream UITask.cpp +// Included by UITask.cpp after SensorPlaceholders.h is defined. + +class SettingsScreen : public UIScreen { + UITask* _task; + + enum SettingItem { + // Display section + SECTION_DISPLAY, + BRIGHTNESS, + AUTO_OFF, + BATT_DISPLAY, + CLOCK_SECONDS, + // Sound section + SECTION_SOUND, + BUZZER, + BUZZER_VOLUME, + DM_MELODY, + CH_MELODY, + // Home pages section + SECTION_HOME_PAGES, + HOME_CLOCK, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT, +#if ENV_INCLUDE_GPS == 1 + HOME_GPS, +#endif +#if UI_SENSORS_PAGE == 1 + HOME_SENSORS, +#endif + HOME_TOOLS, HOME_SHUTDOWN, + // Radio section + SECTION_RADIO, + TX_POWER, + // System section + SECTION_SYSTEM, + TIMEZONE, + LOW_BAT, +#if ENV_INCLUDE_GPS == 1 + // GPS section + SECTION_GPS, + GPS_INTERVAL, +#endif + // Contacts section + SECTION_CONTACTS, DM_FILTER, ROOM_FILTER, + // Messages section + SECTION_MESSAGES, + 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 + }; + + static const int VISIBLE_ITEMS = 4; + static const int ITEM_H = 11; + static const int START_Y = 12; + static const int VAL_X = 80; + + int _selected; + int _scroll; + bool _dirty; + + static const uint16_t AUTO_OFF_OPTS[5]; + static const char* AUTO_OFF_LABELS[5]; + static const int AUTO_OFF_COUNT = 5; +#if ENV_INCLUDE_GPS == 1 + static const uint32_t GPS_INTERVAL_OPTS[6]; + static const char* GPS_INTERVAL_LABELS[6]; + static const int GPS_INTERVAL_COUNT = 6; +#endif + static const uint16_t LOW_BAT_OPTS[7]; + static const char* LOW_BAT_LABELS[7]; + static const int LOW_BAT_COUNT = 7; + static const char* BATT_DISPLAY_LABELS[3]; + static const int BATT_DISPLAY_COUNT = 3; + + 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; + } + + void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) { + const int box_w = 7, box_h = 7, gap = 2; + 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); + } + } + + 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; + } + +#if ENV_INCLUDE_GPS == 1 + int gpsIntervalIndex() { + NodePrefs* p = _task->getNodePrefs(); + if (!p) return 0; + for (int i = 0; i < GPS_INTERVAL_COUNT; i++) + if (GPS_INTERVAL_OPTS[i] == p->gps_interval) return i; + return 0; + } +#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_CONTACTS || item == SECTION_MESSAGES +#if ENV_INCLUDE_GPS == 1 + || item == SECTION_GPS +#endif + ; + } + + 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_CONTACTS) return "Contacts"; + if (item == SECTION_MESSAGES) return "Messages"; +#if ENV_INCLUDE_GPS == 1 + if (item == SECTION_GPS) return "GPS"; +#endif + return ""; + } + + bool isHomePage(int item) const { + return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || + item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || + item == HOME_SHUTDOWN +#if ENV_INCLUDE_GPS == 1 + || item == HOME_GPS +#endif +#if UI_SENSORS_PAGE == 1 + || item == HOME_SENSORS +#endif + ; + } + + uint16_t homePageBit(int item) const { + if (item == HOME_CLOCK) return HP_CLOCK; + if (item == HOME_RECENT) return HP_RECENT; + if (item == HOME_RADIO) return HP_RADIO; + if (item == HOME_BT) return HP_BLUETOOTH; + if (item == HOME_ADVERT) return HP_ADVERT; + if (item == HOME_TOOLS) return HP_TOOLS; + if (item == HOME_SHUTDOWN) return HP_SHUTDOWN; +#if ENV_INCLUDE_GPS == 1 + if (item == HOME_GPS) return HP_GPS; +#endif +#if UI_SENSORS_PAGE == 1 + if (item == HOME_SENSORS) return HP_SENSORS; +#endif + return 0; + } + + const char* homePageLabel(int item) const { + if (item == HOME_CLOCK) return "Clock"; + if (item == HOME_RECENT) return "Recent"; + if (item == HOME_RADIO) return "Radio"; + if (item == HOME_BT) return "Bluetooth"; + if (item == HOME_ADVERT) return "Advert"; + if (item == HOME_TOOLS) return "Tools"; + if (item == HOME_SHUTDOWN) return "Shutdown"; +#if ENV_INCLUDE_GPS == 1 + if (item == HOME_GPS) return "GPS"; +#endif +#if UI_SENSORS_PAGE == 1 + if (item == HOME_SENSORS) return "Sensors"; +#endif + return ""; + } + + bool homePageVisible(int item, const NodePrefs* p) const { + uint16_t bit = homePageBit(item); + if (!bit) return false; + uint16_t mask = (p && p->home_pages_mask) ? p->home_pages_mask : HP_ALL; + return (mask & bit) != 0; + } + + bool isMsgSlot(int item) const { + return item >= MSG_SLOT_0 && item <= MSG_SLOT_9; + } + + int msgSlotIndex(int item) const { + return item - MSG_SLOT_0; + } + + int nextSelectable(int from) const { + for (int i = from + 1; i < (int)Count; i++) + if (!isSection(i)) return i; + return from; + } + + int prevSelectable(int from) const { + for (int i = from - 1; i >= 0; i--) + if (!isSection(i)) return i; + return from; + } + + void renderItem(DisplayDriver& display, int item, int y) { + NodePrefs* p = _task->getNodePrefs(); + + if (isSection(item)) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y, display.width(), 1); + display.setCursor(2, y + 2); + display.print(sectionName(item)); + return; + } + + bool sel = (item == _selected); + + if (sel) { + display.setColor(DisplayDriver::LIGHT); + display.fillRect(0, y - 1, display.width(), ITEM_H); + display.setColor(DisplayDriver::DARK); + } else { + display.setColor(DisplayDriver::LIGHT); + } + + display.setCursor(0, y); + display.print(sel ? ">" : " "); + display.setCursor(8, y); + + if (item == BRIGHTNESS) { + display.print("Bright"); + renderBar(display, VAL_X, y, (p ? p->display_brightness : 2) + 1, 5); + } else if (item == BUZZER) { + display.print("Buzzer"); + display.setCursor(VAL_X, 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("BzrVol"); +#ifdef PIN_BUZZER + renderBar(display, VAL_X, y, _task->getBuzzerVolume() + 1, 5); +#else + display.setCursor(VAL_X, y); + display.print("N/A"); +#endif + } else if (item == DM_MELODY) { + display.print("DM sound"); + display.setCursor(VAL_X, y); + { static const char* L[] = { "built-in", "M1", "M2" }; + uint8_t v = p ? p->notif_melody_dm : 0; + display.print(L[v < 3 ? v : 0]); } + } else if (item == CH_MELODY) { + display.print("Ch sound"); + display.setCursor(VAL_X, y); + { static const char* L[] = { "built-in", "M1", "M2" }; + uint8_t v = p ? p->notif_melody_ch : 0; + display.print(L[v < 3 ? v : 0]); } + } else if (isHomePage(item)) { + display.print(homePageLabel(item)); + display.setCursor(VAL_X, y); + display.print(homePageVisible(item, p) ? "ON" : "OFF"); + } else if (item == TX_POWER) { + display.print("TX Pwr"); + char buf[8]; + sprintf(buf, "%ddBm", p ? p->tx_power_dbm : 0); + display.setCursor(VAL_X, y); + display.print(buf); + } else if (item == AUTO_OFF) { + display.print("AutoOff"); + display.setCursor(VAL_X, y); + display.print(AUTO_OFF_LABELS[autoOffIndex()]); +#if ENV_INCLUDE_GPS == 1 + } else if (item == GPS_INTERVAL) { + display.print("GPS upd"); + display.setCursor(VAL_X, y); + display.print(GPS_INTERVAL_LABELS[gpsIntervalIndex()]); +#endif + } else if (item == TIMEZONE) { + display.print("TimeZone"); + char buf[8]; + int8_t tz = p ? p->tz_offset_hours : 0; + if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz); + else sprintf(buf, "UTC%d", (int)tz); + display.setCursor(VAL_X, y); + display.print(buf); + } else if (item == LOW_BAT) { + display.print("LowBat"); + display.setCursor(VAL_X, y); + display.print(LOW_BAT_LABELS[lowBatIndex()]); + } else if (item == BATT_DISPLAY) { + display.print("BattDisp"); + display.setCursor(VAL_X, y); + uint8_t mode = p ? p->batt_display_mode : 0; + display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); + } else if (item == CLOCK_SECONDS) { + display.print("Seconds"); + display.setCursor(VAL_X, y); + display.print((p && p->clock_hide_seconds) ? "OFF" : "ON"); + } else if (item == DM_FILTER) { + display.print("DM"); + display.setCursor(VAL_X, y); + display.print((p && p->dm_show_all) ? "all" : "fav"); + } else if (item == ROOM_FILTER) { + display.print("Rooms"); + display.setCursor(VAL_X, y); + display.print((p && p->room_fav_only) ? "fav" : "all"); + } 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)"; + display.drawTextEllipsized(VAL_X - 20, y, display.width() - VAL_X + 18, tmpl); + } + } + + // Keyboard state for editing message slots + int _edit_slot; // -1 = not editing, 0..9 = slot being edited + KeyboardWidget _kb; + +public: + SettingsScreen(UITask* task) + : _task(task), _selected(BRIGHTNESS), _scroll(0), _dirty(false), _edit_slot(-1) {} + + + void markClean() { _dirty = false; } + + int render(DisplayDriver& display) override { + display.setTextSize(1); + + if (_edit_slot >= 0) { + return _kb.render(display); + } + + display.setColor(DisplayDriver::LIGHT); + display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); + display.fillRect(0, 10, display.width(), 1); + + for (int i = 0; i < VISIBLE_ITEMS && (_scroll + i) < SettingItem::Count; i++) { + renderItem(display, _scroll + i, START_Y + i * ITEM_H); + } + + // scroll indicators + if (_scroll > 0) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(display.width() - 6, START_Y); + display.print("^"); + } + if (_scroll + VISIBLE_ITEMS < SettingItem::Count) { + display.setColor(DisplayDriver::LIGHT); + display.setCursor(display.width() - 6, START_Y + (VISIBLE_ITEMS - 1) * ITEM_H); + display.print("v"); + } + + return 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; + } + + if (c == KEY_UP) { + int prev = prevSelectable(_selected); + if (prev != _selected) { + _selected = prev; + if (_selected < _scroll) _scroll = _selected; + } + return true; + } + if (c == KEY_DOWN) { + int next = nextSelectable(_selected); + if (next != _selected) { + _selected = next; + if (_selected >= _scroll + VISIBLE_ITEMS) _scroll = _selected - VISIBLE_ITEMS + 1; + } + return true; + } + if (c == KEY_CANCEL) { + if (_dirty) the_mesh.savePrefs(); + _task->gotoHomeScreen(); + return true; + } + + bool right = (c == KEY_RIGHT || c == KEY_NEXT); + bool left = (c == KEY_LEFT || c == KEY_PREV); + bool enter = (c == KEY_ENTER); + + 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; + } + 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 ? 2 : 1)) % 3; + _dirty = true; return true; + } + if (_selected == CH_MELODY && p && (left || right || enter)) { + p->notif_melody_ch = (p->notif_melody_ch + (left ? 2 : 1)) % 3; + _dirty = true; return true; + } + if (isHomePage(_selected) && p && (left || right || enter)) { + p->home_pages_mask ^= homePageBit(_selected); + _dirty = true; + return true; + } + 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 == AUTO_OFF && p) { + int idx = autoOffIndex(); + if (right) idx = (idx + 1) % AUTO_OFF_COUNT; + if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; + if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; } + } +#if ENV_INCLUDE_GPS == 1 + if (_selected == GPS_INTERVAL && p) { + int idx = gpsIntervalIndex(); + if (right) idx = (idx + 1) % GPS_INTERVAL_COUNT; + if (left) idx = (idx + GPS_INTERVAL_COUNT - 1) % GPS_INTERVAL_COUNT; + if (left || right) { + p->gps_interval = GPS_INTERVAL_OPTS[idx]; + _task->applyGPSInterval(); + _dirty = true; + return true; + } + } +#endif + 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) idx = (idx + 1) % LOW_BAT_COUNT; + if (left) idx = (idx + LOW_BAT_COUNT - 1) % LOW_BAT_COUNT; + if (left || right) { p->low_batt_mv = LOW_BAT_OPTS[idx]; _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) idx = (idx + 1) % BATT_DISPLAY_COUNT; + if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; + if (left || right) { p->batt_display_mode = idx; _dirty = true; return true; } + } + if (_selected == CLOCK_SECONDS && p && (left || right || enter)) { + p->clock_hide_seconds ^= 1; + _dirty = true; + return true; + } + if (_selected == DM_FILTER && p && (left || right || enter)) { + p->dm_show_all = p->dm_show_all ? 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 (isMsgSlot(_selected) && enter) { + int slot = msgSlotIndex(_selected); + _edit_slot = slot; + _kb.begin(p ? p->custom_msgs[slot] : ""); + kbAddSensorPlaceholders(_kb, &sensors); + return true; + } + return false; + } +}; + +const uint16_t SettingsScreen::AUTO_OFF_OPTS[5] = { 5, 15, 30, 60, 0 }; +const char* SettingsScreen::AUTO_OFF_LABELS[5] = { "5s", "15s", "30s", "60s", "never" }; +#if ENV_INCLUDE_GPS == 1 +const uint32_t SettingsScreen::GPS_INTERVAL_OPTS[6] = { 0, 30, 60, 300, 900, 1800 }; +const char* SettingsScreen::GPS_INTERVAL_LABELS[6] = { "off", "30s", "1min", "5min", "15min", "30min" }; +#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" }; +const char* SettingsScreen::BATT_DISPLAY_LABELS[3] = { "icon", "%", "V" }; diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index ed17df45..0c5c1f50 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -117,1589 +117,8 @@ static const uint16_t HP_ALL = 0x01FF; #include "KeyboardWidget.h" #include "FullscreenMsgView.h" #include "SensorPlaceholders.h" - -class SettingsScreen : public UIScreen { - UITask* _task; - - enum SettingItem { - // Display section - SECTION_DISPLAY, - BRIGHTNESS, - AUTO_OFF, - BATT_DISPLAY, - CLOCK_SECONDS, - // Sound section - SECTION_SOUND, - BUZZER, - BUZZER_VOLUME, - DM_MELODY, - CH_MELODY, - // Home pages section - SECTION_HOME_PAGES, - HOME_CLOCK, HOME_RECENT, HOME_RADIO, HOME_BT, HOME_ADVERT, -#if ENV_INCLUDE_GPS == 1 - HOME_GPS, -#endif -#if UI_SENSORS_PAGE == 1 - HOME_SENSORS, -#endif - HOME_TOOLS, HOME_SHUTDOWN, - // Radio section - SECTION_RADIO, - TX_POWER, - // System section - SECTION_SYSTEM, - TIMEZONE, - LOW_BAT, -#if ENV_INCLUDE_GPS == 1 - // GPS section - SECTION_GPS, - GPS_INTERVAL, -#endif - // Contacts section - SECTION_CONTACTS, DM_FILTER, ROOM_FILTER, - // Messages section - SECTION_MESSAGES, - 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 - }; - - static const int VISIBLE_ITEMS = 4; - static const int ITEM_H = 11; - static const int START_Y = 12; - static const int VAL_X = 80; - - int _selected; - int _scroll; - bool _dirty; - - static const uint16_t AUTO_OFF_OPTS[5]; - static const char* AUTO_OFF_LABELS[5]; - static const int AUTO_OFF_COUNT = 5; -#if ENV_INCLUDE_GPS == 1 - static const uint32_t GPS_INTERVAL_OPTS[6]; - static const char* GPS_INTERVAL_LABELS[6]; - static const int GPS_INTERVAL_COUNT = 6; -#endif - static const uint16_t LOW_BAT_OPTS[7]; - static const char* LOW_BAT_LABELS[7]; - static const int LOW_BAT_COUNT = 7; - static const char* BATT_DISPLAY_LABELS[3]; - static const int BATT_DISPLAY_COUNT = 3; - - 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; - } - - void renderBar(DisplayDriver& display, int x, int y, int value, int max_val) { - const int box_w = 7, box_h = 7, gap = 2; - 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); - } - } - - 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; - } - -#if ENV_INCLUDE_GPS == 1 - int gpsIntervalIndex() { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - for (int i = 0; i < GPS_INTERVAL_COUNT; i++) - if (GPS_INTERVAL_OPTS[i] == p->gps_interval) return i; - return 0; - } -#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_CONTACTS || item == SECTION_MESSAGES -#if ENV_INCLUDE_GPS == 1 - || item == SECTION_GPS -#endif - ; - } - - 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_CONTACTS) return "Contacts"; - if (item == SECTION_MESSAGES) return "Messages"; -#if ENV_INCLUDE_GPS == 1 - if (item == SECTION_GPS) return "GPS"; -#endif - return ""; - } - - bool isHomePage(int item) const { - return item == HOME_CLOCK || item == HOME_RECENT || item == HOME_RADIO || - item == HOME_BT || item == HOME_ADVERT || item == HOME_TOOLS || - item == HOME_SHUTDOWN -#if ENV_INCLUDE_GPS == 1 - || item == HOME_GPS -#endif -#if UI_SENSORS_PAGE == 1 - || item == HOME_SENSORS -#endif - ; - } - - uint16_t homePageBit(int item) const { - if (item == HOME_CLOCK) return HP_CLOCK; - if (item == HOME_RECENT) return HP_RECENT; - if (item == HOME_RADIO) return HP_RADIO; - if (item == HOME_BT) return HP_BLUETOOTH; - if (item == HOME_ADVERT) return HP_ADVERT; - if (item == HOME_TOOLS) return HP_TOOLS; - if (item == HOME_SHUTDOWN) return HP_SHUTDOWN; -#if ENV_INCLUDE_GPS == 1 - if (item == HOME_GPS) return HP_GPS; -#endif -#if UI_SENSORS_PAGE == 1 - if (item == HOME_SENSORS) return HP_SENSORS; -#endif - return 0; - } - - const char* homePageLabel(int item) const { - if (item == HOME_CLOCK) return "Clock"; - if (item == HOME_RECENT) return "Recent"; - if (item == HOME_RADIO) return "Radio"; - if (item == HOME_BT) return "Bluetooth"; - if (item == HOME_ADVERT) return "Advert"; - if (item == HOME_TOOLS) return "Tools"; - if (item == HOME_SHUTDOWN) return "Shutdown"; -#if ENV_INCLUDE_GPS == 1 - if (item == HOME_GPS) return "GPS"; -#endif -#if UI_SENSORS_PAGE == 1 - if (item == HOME_SENSORS) return "Sensors"; -#endif - return ""; - } - - bool homePageVisible(int item, const NodePrefs* p) const { - uint16_t bit = homePageBit(item); - if (!bit) return false; - uint16_t mask = (p && p->home_pages_mask) ? p->home_pages_mask : HP_ALL; - return (mask & bit) != 0; - } - - bool isMsgSlot(int item) const { - return item >= MSG_SLOT_0 && item <= MSG_SLOT_9; - } - - int msgSlotIndex(int item) const { - return item - MSG_SLOT_0; - } - - int nextSelectable(int from) const { - for (int i = from + 1; i < (int)Count; i++) - if (!isSection(i)) return i; - return from; - } - - int prevSelectable(int from) const { - for (int i = from - 1; i >= 0; i--) - if (!isSection(i)) return i; - return from; - } - - void renderItem(DisplayDriver& display, int item, int y) { - NodePrefs* p = _task->getNodePrefs(); - - if (isSection(item)) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width(), 1); - display.setCursor(2, y + 2); - display.print(sectionName(item)); - return; - } - - bool sel = (item == _selected); - - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - - display.setCursor(0, y); - display.print(sel ? ">" : " "); - display.setCursor(8, y); - - if (item == BRIGHTNESS) { - display.print("Bright"); - renderBar(display, VAL_X, y, (p ? p->display_brightness : 2) + 1, 5); - } else if (item == BUZZER) { - display.print("Buzzer"); - display.setCursor(VAL_X, 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("BzrVol"); -#ifdef PIN_BUZZER - renderBar(display, VAL_X, y, _task->getBuzzerVolume() + 1, 5); -#else - display.setCursor(VAL_X, y); - display.print("N/A"); -#endif - } else if (item == DM_MELODY) { - display.print("DM sound"); - display.setCursor(VAL_X, y); - { static const char* L[] = { "built-in", "M1", "M2" }; - uint8_t v = p ? p->notif_melody_dm : 0; - display.print(L[v < 3 ? v : 0]); } - } else if (item == CH_MELODY) { - display.print("Ch sound"); - display.setCursor(VAL_X, y); - { static const char* L[] = { "built-in", "M1", "M2" }; - uint8_t v = p ? p->notif_melody_ch : 0; - display.print(L[v < 3 ? v : 0]); } - } else if (isHomePage(item)) { - display.print(homePageLabel(item)); - display.setCursor(VAL_X, y); - display.print(homePageVisible(item, p) ? "ON" : "OFF"); - } else if (item == TX_POWER) { - display.print("TX Pwr"); - char buf[8]; - sprintf(buf, "%ddBm", p ? p->tx_power_dbm : 0); - display.setCursor(VAL_X, y); - display.print(buf); - } else if (item == AUTO_OFF) { - display.print("AutoOff"); - display.setCursor(VAL_X, y); - display.print(AUTO_OFF_LABELS[autoOffIndex()]); -#if ENV_INCLUDE_GPS == 1 - } else if (item == GPS_INTERVAL) { - display.print("GPS upd"); - display.setCursor(VAL_X, y); - display.print(GPS_INTERVAL_LABELS[gpsIntervalIndex()]); -#endif - } else if (item == TIMEZONE) { - display.print("TimeZone"); - char buf[8]; - int8_t tz = p ? p->tz_offset_hours : 0; - if (tz >= 0) sprintf(buf, "UTC+%d", (int)tz); - else sprintf(buf, "UTC%d", (int)tz); - display.setCursor(VAL_X, y); - display.print(buf); - } else if (item == LOW_BAT) { - display.print("LowBat"); - display.setCursor(VAL_X, y); - display.print(LOW_BAT_LABELS[lowBatIndex()]); - } else if (item == BATT_DISPLAY) { - display.print("BattDisp"); - display.setCursor(VAL_X, y); - uint8_t mode = p ? p->batt_display_mode : 0; - display.print(BATT_DISPLAY_LABELS[mode < BATT_DISPLAY_COUNT ? mode : 0]); - } else if (item == CLOCK_SECONDS) { - display.print("Seconds"); - display.setCursor(VAL_X, y); - display.print((p && p->clock_hide_seconds) ? "OFF" : "ON"); - } else if (item == DM_FILTER) { - display.print("DM"); - display.setCursor(VAL_X, y); - display.print((p && p->dm_show_all) ? "all" : "fav"); - } else if (item == ROOM_FILTER) { - display.print("Rooms"); - display.setCursor(VAL_X, y); - display.print((p && p->room_fav_only) ? "fav" : "all"); - } 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)"; - display.drawTextEllipsized(VAL_X - 20, y, display.width() - VAL_X + 18, tmpl); - } - } - - // Keyboard state for editing message slots - int _edit_slot; // -1 = not editing, 0..9 = slot being edited - KeyboardWidget _kb; - -public: - SettingsScreen(UITask* task) - : _task(task), _selected(BRIGHTNESS), _scroll(0), _dirty(false), _edit_slot(-1) {} - - - void markClean() { _dirty = false; } - - int render(DisplayDriver& display) override { - display.setTextSize(1); - - if (_edit_slot >= 0) { - return _kb.render(display); - } - - display.setColor(DisplayDriver::LIGHT); - display.drawTextCentered(display.width() / 2, 0, "SETTINGS"); - display.fillRect(0, 10, display.width(), 1); - - for (int i = 0; i < VISIBLE_ITEMS && (_scroll + i) < SettingItem::Count; i++) { - renderItem(display, _scroll + i, START_Y + i * ITEM_H); - } - - // scroll indicators - if (_scroll > 0) { - display.setColor(DisplayDriver::LIGHT); - display.setCursor(display.width() - 6, START_Y); - display.print("^"); - } - if (_scroll + VISIBLE_ITEMS < SettingItem::Count) { - display.setColor(DisplayDriver::LIGHT); - display.setCursor(display.width() - 6, START_Y + (VISIBLE_ITEMS - 1) * ITEM_H); - display.print("v"); - } - - return 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; - } - - if (c == KEY_UP) { - int prev = prevSelectable(_selected); - if (prev != _selected) { - _selected = prev; - if (_selected < _scroll) _scroll = _selected; - } - return true; - } - if (c == KEY_DOWN) { - int next = nextSelectable(_selected); - if (next != _selected) { - _selected = next; - if (_selected >= _scroll + VISIBLE_ITEMS) _scroll = _selected - VISIBLE_ITEMS + 1; - } - return true; - } - if (c == KEY_CANCEL) { - if (_dirty) the_mesh.savePrefs(); - _task->gotoHomeScreen(); - return true; - } - - bool right = (c == KEY_RIGHT || c == KEY_NEXT); - bool left = (c == KEY_LEFT || c == KEY_PREV); - bool enter = (c == KEY_ENTER); - - 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; - } - 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 ? 2 : 1)) % 3; - _dirty = true; return true; - } - if (_selected == CH_MELODY && p && (left || right || enter)) { - p->notif_melody_ch = (p->notif_melody_ch + (left ? 2 : 1)) % 3; - _dirty = true; return true; - } - if (isHomePage(_selected) && p && (left || right || enter)) { - p->home_pages_mask ^= homePageBit(_selected); - _dirty = true; - return true; - } - 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 == AUTO_OFF && p) { - int idx = autoOffIndex(); - if (right) idx = (idx + 1) % AUTO_OFF_COUNT; - if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; - if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; } - } -#if ENV_INCLUDE_GPS == 1 - if (_selected == GPS_INTERVAL && p) { - int idx = gpsIntervalIndex(); - if (right) idx = (idx + 1) % GPS_INTERVAL_COUNT; - if (left) idx = (idx + GPS_INTERVAL_COUNT - 1) % GPS_INTERVAL_COUNT; - if (left || right) { - p->gps_interval = GPS_INTERVAL_OPTS[idx]; - _task->applyGPSInterval(); - _dirty = true; - return true; - } - } -#endif - 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) idx = (idx + 1) % LOW_BAT_COUNT; - if (left) idx = (idx + LOW_BAT_COUNT - 1) % LOW_BAT_COUNT; - if (left || right) { p->low_batt_mv = LOW_BAT_OPTS[idx]; _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) idx = (idx + 1) % BATT_DISPLAY_COUNT; - if (left) idx = (idx + BATT_DISPLAY_COUNT - 1) % BATT_DISPLAY_COUNT; - if (left || right) { p->batt_display_mode = idx; _dirty = true; return true; } - } - if (_selected == CLOCK_SECONDS && p && (left || right || enter)) { - p->clock_hide_seconds ^= 1; - _dirty = true; - return true; - } - if (_selected == DM_FILTER && p && (left || right || enter)) { - p->dm_show_all = p->dm_show_all ? 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 (isMsgSlot(_selected) && enter) { - int slot = msgSlotIndex(_selected); - _edit_slot = slot; - _kb.begin(p ? p->custom_msgs[slot] : ""); - kbAddSensorPlaceholders(_kb, &sensors); - return true; - } - return false; - } -}; - -const uint16_t SettingsScreen::AUTO_OFF_OPTS[5] = { 5, 15, 30, 60, 0 }; -const char* SettingsScreen::AUTO_OFF_LABELS[5] = { "5s", "15s", "30s", "60s", "never" }; -#if ENV_INCLUDE_GPS == 1 -const uint32_t SettingsScreen::GPS_INTERVAL_OPTS[6] = { 0, 30, 60, 300, 900, 1800 }; -const char* SettingsScreen::GPS_INTERVAL_LABELS[6] = { "off", "30s", "1min", "5min", "15min", "30min" }; -#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" }; -const char* SettingsScreen::BATT_DISPLAY_LABELS[3] = { "icon", "%", "V" }; - -class QuickMsgScreen : public UIScreen { - UITask* _task; - - enum Phase { MODE_SELECT, CONTACT_PICK, DM_HIST, MSG_PICK, CHANNEL_PICK, CHANNEL_HIST, KEYBOARD }; - Phase _phase; - - // MODE_SELECT - int _mode_sel; // 0=Direct, 1=Channel, 2=Room Servers - - // CONTACT_PICK - int _contact_sel, _contact_scroll; - int _num_contacts; - uint16_t _sorted[MAX_CONTACTS]; - ContactInfo _sel_contact; - bool _room_mode; // true = picking a room server, false = picking a DM contact - - // CHANNEL_PICK - int _channel_sel, _channel_scroll; - int _num_channels; - uint8_t _channel_indices[MAX_GROUP_CHANNELS]; - uint8_t _ch_unread[MAX_GROUP_CHANNELS]; - int _sel_channel_idx; - bool _sending_to_channel; - - // MSG_PICK (shared) - int _msg_sel, _msg_scroll; - int _active_msgs[QUICK_MSGS_MAX]; - int _active_msg_count; - - // CHANNEL_HIST - int _hist_sel, _hist_scroll; - FullscreenMsgView _fs; - int _unread_at_entry; // _ch_unread value when entering CHANNEL_HIST - int _viewing_max_seen; // highest _hist_sel reached in current session - static const int CH_HIST_MAX = 32; - - // KEYBOARD - KeyboardWidget _kb; - - // Context menu (opened by KEY_CONTEXT_MENU in CHANNEL_PICK / CONTACT_PICK) - PopupMenu _ctx_menu; - bool _ctx_dirty; - char _ctx_notif_item[22]; - char _ctx_melody_item[20]; - - struct ChHistEntry { uint8_t ch_idx; char text[140]; }; - ChHistEntry _hist[CH_HIST_MAX]; - int _hist_head, _hist_count; - - // DM_HIST - struct DmHistEntry { uint8_t prefix[4]; uint8_t outgoing; char text[80]; }; - static const int DM_HIST_MAX = 64; - DmHistEntry _dm_hist[DM_HIST_MAX]; - int _dm_hist_head, _dm_hist_count; - int _dm_hist_sel, _dm_hist_scroll; - FullscreenMsgView _dm_fs; - - static const int VISIBLE = 4; - static const int ITEM_H = 12; - static const int START_Y = 12; - static const int HIST_VISIBLE = 2; // 2-line boxed history entries - static const int HIST_ITEM_H = 21; // box(19) + gap(2) - static const int HIST_BOX_H = 19; - static const int HIST_START_Y = 11; - - void expandMsg(const char* tmpl, char* out, int out_len) const { - double lat = 0, lon = 0; - bool gps_valid = false; -#if ENV_INCLUDE_GPS == 1 - LocationProvider* loc = sensors.getLocationProvider(); - if (loc && loc->isValid()) { - lat = loc->getLatitude() / 1000000.0; - lon = loc->getLongitude() / 1000000.0; - gps_valid = true; - } -#endif - NodePrefs* np = _task->getNodePrefs(); - float batt = (float)board.getBattMilliVolts() / 1000.0f; - ::expandMsg(tmpl, out, out_len, lat, lon, gps_valid, - rtc_clock.getCurrentTime(), - np ? np->tz_offset_hours : 0, - &sensors, batt); - } - - void setupMsgPick() { - _msg_sel = _msg_scroll = 0; - _active_msg_count = 0; - NodePrefs* p = _task->getNodePrefs(); - if (p) { - for (int i = 0; i < QUICK_MSGS_MAX; i++) { - if (p->custom_msgs[i][0] != '\0') - _active_msgs[_active_msg_count++] = i; - } - } - } - - void renderScrollHints(DisplayDriver& display, int scroll, int count) { - display.setColor(DisplayDriver::LIGHT); - if (scroll > 0) { - display.setCursor(display.width() - 6, START_Y); - display.print("^"); - } - if (scroll + VISIBLE < count) { - display.setCursor(display.width() - 6, START_Y + (VISIBLE-1)*ITEM_H); - display.print("v"); - } - } - - // count history entries for a specific channel - int histCountForChannel(int ch_idx) const { - int n = 0; - for (int i = 0; i < _hist_count; i++) { - if (_hist[(_hist_head + i) % CH_HIST_MAX].ch_idx == (uint8_t)ch_idx) n++; - } - return n; - } - - // get ring-buffer position of j-th history entry for channel (newest first) - int histEntryForChannel(int ch_idx, int j) const { - int n = 0; - for (int i = _hist_count - 1; i >= 0; i--) { - int pos = (_hist_head + i) % CH_HIST_MAX; - if (_hist[pos].ch_idx == (uint8_t)ch_idx) { - if (n == j) return pos; - n++; - } - } - return -1; - } - - void storeDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { - int pos; - if (_dm_hist_count < DM_HIST_MAX) { - pos = (_dm_hist_head + _dm_hist_count) % DM_HIST_MAX; - _dm_hist_count++; - } else { - pos = _dm_hist_head; - _dm_hist_head = (_dm_hist_head + 1) % DM_HIST_MAX; - } - memcpy(_dm_hist[pos].prefix, pub_key, 4); - _dm_hist[pos].outgoing = outgoing ? 1 : 0; - strncpy(_dm_hist[pos].text, text, sizeof(DmHistEntry::text) - 1); - _dm_hist[pos].text[sizeof(DmHistEntry::text) - 1] = '\0'; - } - - int dmHistCountForContact(const uint8_t* prefix) const { - int n = 0; - for (int i = 0; i < _dm_hist_count; i++) - if (memcmp(_dm_hist[(_dm_hist_head + i) % DM_HIST_MAX].prefix, prefix, 4) == 0) n++; - return n; - } - - int dmHistEntryForContact(const uint8_t* prefix, int j) const { // j=0 = newest - int n = 0; - for (int i = _dm_hist_count - 1; i >= 0; i--) { - int pos = (_dm_hist_head + i) % DM_HIST_MAX; - if (memcmp(_dm_hist[pos].prefix, prefix, 4) == 0) { - if (n == j) return pos; - n++; - } - } - return -1; - } - - void afterSend(bool ok, const char* msg) { - if (ok && _sending_to_channel) { - _hist_sel = 0; - _hist_scroll = 0; - _phase = CHANNEL_HIST; // set before addChannelMsg so viewing=true, no unread bump - char entry[sizeof(ChHistEntry::text)]; - snprintf(entry, sizeof(entry), "Me: %s", msg); - addChannelMsg(_sel_channel_idx, entry); - // After inserting sent msg at index 0, the unread index range is stale. - // User is active in this channel — treat as fully read. - _ch_unread[_sel_channel_idx] = 0; - _unread_at_entry = 0; - _viewing_max_seen = 0; - _task->showAlert("Sent!", 600); - } else if (ok) { - storeDMMsg(_sel_contact.id.pub_key, true, msg); - _dm_hist_sel = 0; - _dm_hist_scroll = 0; - _phase = DM_HIST; - _task->showAlert("Sent!", 600); - } else { - _task->showAlert("Send failed", 1500); - _task->gotoHomeScreen(); - } - } - - bool sendText(const char* msg) { - if (_sending_to_channel) { - ChannelDetails ch; - if (!the_mesh.getChannel(_sel_channel_idx, ch)) return false; - return the_mesh.sendGroupMessage(rtc_clock.getCurrentTime(), ch.channel, - the_mesh.getNodeName(), msg, strlen(msg)); - } else { - uint32_t expected_ack = 0, est_timeout = 0; - return the_mesh.sendMessage(_sel_contact, rtc_clock.getCurrentTime(), 0, - msg, expected_ack, est_timeout) > 0; - } - } - - void buildContactList() { - NodePrefs* p = _task->getNodePrefs(); - ContactInfo c; - int total = the_mesh.getNumContacts(); - _num_contacts = 0; - if (_room_mode) { - bool fav_only = (p && p->room_fav_only); - for (int i = 0; i < total; i++) { - if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_ROOM) continue; - if (fav_only && !(c.flags & 0x01)) continue; - _sorted[_num_contacts++] = i; - } - } else { - bool show_all = (p && p->dm_show_all); - for (int i = 0; i < total; i++) { - if (!the_mesh.getContactByIdx(i, c) || c.type != ADV_TYPE_CHAT) continue; - if (!show_all && !(c.flags & 0x01)) continue; - _sorted[_num_contacts++] = i; - } - } - } - - void buildChannelList() { - _num_channels = 0; - for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { - ChannelDetails ch; - if (the_mesh.getChannel(i, ch) && ch.name[0] != '\0') { - _channel_indices[_num_channels++] = (uint8_t)i; - } - } - } - - // Returns per-channel notification state: 0=follow global, 1=muted, 2=force-on - uint8_t chNotifState(uint8_t ch_idx) const { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - uint64_t mask = 1ULL << ch_idx; - if (!(p->ch_notif_override & mask)) return 0; - return (p->ch_notif_muted & mask) ? 1 : 2; - } - - void setChNotifState(uint8_t ch_idx, uint8_t state) { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - uint64_t mask = 1ULL << ch_idx; - if (state == 0) { - p->ch_notif_override &= ~mask; - p->ch_notif_muted &= ~mask; - } else if (state == 1) { - p->ch_notif_override |= mask; - p->ch_notif_muted |= mask; - } else { - p->ch_notif_override |= mask; - p->ch_notif_muted &= ~mask; - } - } - - uint8_t dmNotifState(const uint8_t* pub_key) const { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) - if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) - return p->dm_notif[i].state; - return 0; - } - - void setDmNotifState(const uint8_t* pub_key, uint8_t state) { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { - if (p->dm_notif[i].state && memcmp(p->dm_notif[i].prefix, pub_key, 4) == 0) { - if (state == 0) { memset(&p->dm_notif[i], 0, sizeof(p->dm_notif[i])); } - else p->dm_notif[i].state = state; - return; - } - } - if (state == 0) return; - for (int i = 0; i < NodePrefs::DM_NOTIF_TABLE_MAX; i++) { - if (p->dm_notif[i].state == 0) { - memcpy(p->dm_notif[i].prefix, pub_key, 4); - p->dm_notif[i].state = state; - return; - } - } - // table full — overwrite slot 0 - memcpy(p->dm_notif[0].prefix, pub_key, 4); - p->dm_notif[0].state = state; - } - - uint8_t chNotifMelody(uint8_t ch_idx) const { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - uint64_t mask = 1ULL << ch_idx; - if (!(p->ch_notif_melody_set & mask)) return 0; - return (p->ch_notif_melody_2 & mask) ? 2 : 1; - } - - void setChNotifMelody(uint8_t ch_idx, uint8_t slot) { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - uint64_t mask = 1ULL << ch_idx; - if (slot == 0) { - p->ch_notif_melody_set &= ~mask; - p->ch_notif_melody_2 &= ~mask; - } else if (slot == 1) { - p->ch_notif_melody_set |= mask; - p->ch_notif_melody_2 &= ~mask; - } else { - p->ch_notif_melody_set |= mask; - p->ch_notif_melody_2 |= mask; - } - } - - uint8_t dmMelodySlot(const uint8_t* pub_key) const { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return 0; - for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) - if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) - return p->dm_melody[i].slot; - return 0; - } - - void setDmMelody(const uint8_t* pub_key, uint8_t slot) { - NodePrefs* p = _task->getNodePrefs(); - if (!p) return; - for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { - if (p->dm_melody[i].slot && memcmp(p->dm_melody[i].prefix, pub_key, 4) == 0) { - if (slot == 0) memset(&p->dm_melody[i], 0, sizeof(p->dm_melody[i])); - else p->dm_melody[i].slot = slot; - return; - } - } - if (slot == 0) return; - for (int i = 0; i < NodePrefs::DM_MELODY_TABLE_MAX; i++) { - if (p->dm_melody[i].slot == 0) { - memcpy(p->dm_melody[i].prefix, pub_key, 4); - p->dm_melody[i].slot = slot; - return; - } - } - memcpy(p->dm_melody[0].prefix, pub_key, 4); - p->dm_melody[0].slot = slot; - } - -public: - QuickMsgScreen(UITask* task) - : _task(task), _phase(MODE_SELECT), _mode_sel(0), - _contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), - _channel_sel(0), _channel_scroll(0), _num_channels(0), - _sel_channel_idx(0), _sending_to_channel(false), - _msg_sel(0), _msg_scroll(0), _active_msg_count(0), - _hist_sel(0), _hist_scroll(0), - _unread_at_entry(0), _viewing_max_seen(0), - _hist_head(0), _hist_count(0), - _dm_hist_head(0), _dm_hist_count(0), - _dm_hist_sel(-1), _dm_hist_scroll(0), - _ctx_dirty(false) { - memset(_ch_unread, 0, sizeof(_ch_unread)); - } - - void addChannelMsg(uint8_t ch_idx, const char* text) { - int pos; - if (_hist_count < CH_HIST_MAX) { - pos = (_hist_head + _hist_count) % CH_HIST_MAX; - _hist_count++; - } else { - pos = _hist_head; - _hist_head = (_hist_head + 1) % CH_HIST_MAX; - } - _hist[pos].ch_idx = ch_idx; - strncpy(_hist[pos].text, text, sizeof(_hist[pos].text) - 1); - _hist[pos].text[sizeof(_hist[pos].text) - 1] = '\0'; - - if (ch_idx < MAX_GROUP_CHANNELS) { - bool viewing = (_phase == CHANNEL_HIST && _sel_channel_idx == (int)ch_idx); - if (!viewing && _ch_unread[ch_idx] < 99) _ch_unread[ch_idx]++; - } - } - - void addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text) { - storeDMMsg(pub_key, outgoing, text); - } - - int getDMUnreadTotal() const { - return _task->getDMUnreadTotal(); - } - - int getTotalChannelUnread() const { - int total = 0; - for (int i = 0; i < MAX_GROUP_CHANNELS; i++) total += _ch_unread[i]; - return total; - } - - void clearAllChannelUnread() { - memset(_ch_unread, 0, sizeof(_ch_unread)); - } - - void updateChannelUnread() { - if (_hist_sel < 0 || _sel_channel_idx < 0 || _sel_channel_idx >= MAX_GROUP_CHANNELS) return; - if (_hist_sel > _viewing_max_seen) _viewing_max_seen = _hist_sel; - // histEntryForChannel is newest-first: index 0 = newest (unread), higher = older. - // Each step down from 0 sees one more message; seen count = max_seen + 1. - int remaining = _unread_at_entry - (_viewing_max_seen + 1); - _ch_unread[_sel_channel_idx] = (uint8_t)(remaining > 0 ? remaining : 0); - } - - void reset() { - _phase = MODE_SELECT; - _mode_sel = 0; - _contact_sel = _contact_scroll = 0; - _msg_sel = _msg_scroll = 0; - _channel_sel = _channel_scroll = 0; - _sending_to_channel = false; - - _room_mode = false; - buildContactList(); - buildChannelList(); - - _ctx_menu.active = false; - _ctx_dirty = false; - _unread_at_entry = 0; - _viewing_max_seen = 0; - } - - int render(DisplayDriver& display) override { - display.setTextSize(1); - display.setColor(DisplayDriver::LIGHT); - - if (_phase == MODE_SELECT) { - display.drawTextCentered(display.width()/2, 0, "MESSAGE"); - display.fillRect(0, 10, display.width(), 1); - const char* opts[] = { "Direct message", "Channels", "Room Servers" }; - int badges[3] = { - getDMUnreadTotal(), - _task->getChannelUnreadCount(), - _task->getRoomUnreadCount() - }; - for (int i = 0; i < 3; i++) { - int y = START_Y + i * ITEM_H; - bool sel = (i == _mode_sel); - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - display.setCursor(0, y); - display.print(sel ? ">" : " "); - display.setCursor(8, y); - display.print(opts[i]); - if (badges[i] > 0) { - char badge[5]; - snprintf(badge, sizeof(badge), "%d", badges[i]); - int bw = display.getTextWidth(badge) + 2; - display.setCursor(display.width() - bw, y); - display.print(badge); - } - } - display.setColor(DisplayDriver::LIGHT); - - } else if (_phase == CONTACT_PICK) { - display.drawTextCentered(display.width()/2, 0, _room_mode ? "SELECT ROOM" : "SELECT CONTACT"); - display.fillRect(0, 10, display.width(), 1); - - if (_num_contacts == 0) { - display.drawTextCentered(display.width()/2, 32, _room_mode ? "No room servers" : "No favourites"); - return 5000; - } - - for (int i = 0; i < VISIBLE && (_contact_scroll+i) < _num_contacts; i++) { - int list_idx = _contact_scroll + i; - int mesh_idx = _sorted[list_idx]; - bool sel = (list_idx == _contact_sel); - int y = START_Y + i * ITEM_H; - - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - - ContactInfo c; - if (the_mesh.getContactByIdx(mesh_idx, c)) { - display.setCursor(0, y); - display.print(sel ? ">" : " "); - char filtered[sizeof(c.name)]; - display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered)); - uint8_t dm_unread = _task->getDMUnread(c.id.pub_key); - char badge[5] = ""; - int bw = 0; - if (dm_unread > 0) { - snprintf(badge, sizeof(badge), "%d", (int)dm_unread); - bw = display.getTextWidth(badge) + 2; - } - display.drawTextEllipsized(8, y, display.width() - 8 - bw - 1, filtered); - if (dm_unread > 0) { - display.setCursor(display.width() - bw + 1, y); - display.print(badge); - } - } - } - display.setColor(DisplayDriver::LIGHT); - renderScrollHints(display, _contact_scroll, _num_contacts); - - // Context menu overlay - if (_ctx_menu.active) _ctx_menu.render(display); - - } else if (_phase == CHANNEL_PICK) { - display.drawTextCentered(display.width()/2, 0, "SELECT CHANNEL"); - display.fillRect(0, 10, display.width(), 1); - - if (_num_channels == 0) { - display.drawTextCentered(display.width()/2, 32, "No channels"); - return 5000; - } - - for (int i = 0; i < VISIBLE && (_channel_scroll+i) < _num_channels; i++) { - int list_idx = _channel_scroll + i; - bool sel = (list_idx == _channel_sel); - int y = START_Y + i * ITEM_H; - - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - display.setCursor(0, y); - display.print(sel ? ">" : " "); - ChannelDetails ch; - if (the_mesh.getChannel(_channel_indices[list_idx], ch)) { - uint8_t unread = _ch_unread[_channel_indices[list_idx]]; - char badge[5] = ""; - int bw = 0; - if (unread > 0) { - snprintf(badge, sizeof(badge), "%d", (int)unread); - bw = display.getTextWidth(badge) + 2; - } - display.drawTextEllipsized(8, y, display.width() - 10 - bw, ch.name); - if (unread > 0) { - display.setCursor(display.width() - bw, y); - display.print(badge); - } - } - } - display.setColor(DisplayDriver::LIGHT); - renderScrollHints(display, _channel_scroll, _num_channels); - - // Context menu overlay - if (_ctx_menu.active) _ctx_menu.render(display); - - } else if (_phase == DM_HIST) { - display.setTextSize(1); - char filtered_name[sizeof(_sel_contact.name)]; - display.translateUTF8ToBlocks(filtered_name, _sel_contact.name, sizeof(filtered_name)); - - if (_dm_fs.active && _dm_hist_sel >= 0) { - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, _dm_hist_sel); - if (ring_pos >= 0) { - const DmHistEntry& e = _dm_hist[ring_pos]; - const char* sender = e.outgoing ? "Me" : filtered_name; - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); - return _dm_fs.render(display, sender, e.text, - _dm_hist_sel < dm_count - 1, - _dm_hist_sel > 0); - } - return 500; - } - - char title[24]; - display.setColor(DisplayDriver::LIGHT); - snprintf(title, sizeof(title), "%.23s", filtered_name); - display.drawTextCentered(display.width()/2, 0, title); - display.fillRect(0, 9, display.width(), 1); - - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); - - for (int i = 0; i < HIST_VISIBLE && (_dm_hist_scroll + i) < dm_count; i++) { - int item = _dm_hist_scroll + i; - bool sel = (item == _dm_hist_sel); - int y = HIST_START_Y + i * HIST_ITEM_H; - - int ring_pos = dmHistEntryForContact(_sel_contact.id.pub_key, item); - if (ring_pos < 0) continue; - - const DmHistEntry& e = _dm_hist[ring_pos]; - const char* sender = e.outgoing ? "Me" : filtered_name; - - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width(), HIST_BOX_H); - display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); - display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width(), HIST_BOX_H); - display.fillRect(1, y + 1, display.width() - 2, 8); - display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); - display.setColor(DisplayDriver::LIGHT); - display.drawTextEllipsized(3, y + 10, display.width() - 6, e.text); - } - } - - if (dm_count == 0) { - display.setColor(DisplayDriver::LIGHT); - display.drawTextCentered(display.width()/2, 32, "No messages yet"); - } - - display.setColor(DisplayDriver::LIGHT); - if (_dm_hist_scroll > 0) { - display.setCursor(display.width() - 6, HIST_START_Y + 1); - display.print("^"); - } - if (_dm_hist_scroll + HIST_VISIBLE < dm_count) { - display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); - display.print("v"); - } - - bool compose_sel = (_dm_hist_sel == -1); - const char* ctxt = "[+ send]"; - int ctw = display.getTextWidth(ctxt); - int cbx = 1, cby = 55; - if (compose_sel) { - display.fillRect(cbx, cby - 1, ctw + 4, 9); - display.setColor(DisplayDriver::DARK); - } - display.setCursor(cbx + 2, cby); - display.print(ctxt); - display.setColor(DisplayDriver::LIGHT); - return dm_count > 0 ? 500 : 2000; - - } else if (_phase == CHANNEL_HIST) { - if (_fs.active && _hist_sel >= 0) { - int fs_hist_count = histCountForChannel(_sel_channel_idx); - int ring_pos = histEntryForChannel(_sel_channel_idx, _hist_sel); - if (ring_pos >= 0) { - const char* ftext = _hist[ring_pos].text; - const char* fsep = strstr(ftext, ": "); - char fsender[22], fmsg[140]; - if (fsep) { - int nl = fsep - ftext; if (nl > 21) nl = 21; - strncpy(fsender, ftext, nl); fsender[nl] = '\0'; - strncpy(fmsg, fsep + 2, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; - } else { - strncpy(fsender, "?", sizeof(fsender)); - strncpy(fmsg, ftext, sizeof(fmsg) - 1); fmsg[sizeof(fmsg)-1] = '\0'; - } - return _fs.render(display, fsender, fmsg, - _hist_sel < fs_hist_count - 1, - _hist_sel > 0); - } - return 2000; - } - - ChannelDetails ch; - the_mesh.getChannel(_sel_channel_idx, ch); - char title[24]; - snprintf(title, sizeof(title), "%.23s", ch.name); - display.drawTextCentered(display.width()/2, 0, title); - display.fillRect(0, 9, display.width(), 1); - - int ch_hist_count = histCountForChannel(_sel_channel_idx); - - for (int i = 0; i < HIST_VISIBLE && (_hist_scroll + i) < ch_hist_count; i++) { - int item = _hist_scroll + i; - bool sel = (item == _hist_sel); - int y = HIST_START_Y + i * HIST_ITEM_H; - - int ring_pos = histEntryForChannel(_sel_channel_idx, item); - if (ring_pos < 0) continue; - - const char* text = _hist[ring_pos].text; - const char* sep = strstr(text, ": "); - char sender[22], msg_part[79]; - if (sep) { - int nl = sep - text; if (nl > 21) nl = 21; - strncpy(sender, text, nl); sender[nl] = '\0'; - strncpy(msg_part, sep + 2, sizeof(msg_part) - 1); - msg_part[sizeof(msg_part) - 1] = '\0'; - } else { - strncpy(sender, "?", sizeof(sender)); - strncpy(msg_part, text, sizeof(msg_part) - 1); - msg_part[sizeof(msg_part) - 1] = '\0'; - } - - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y, display.width(), HIST_BOX_H); - display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); - display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(0, y, display.width(), HIST_BOX_H); - display.fillRect(1, y + 1, display.width() - 2, 8); - display.setColor(DisplayDriver::DARK); - display.drawTextEllipsized(3, y + 1, display.width() - 6, sender); - display.setColor(DisplayDriver::LIGHT); - display.drawTextEllipsized(3, y + 10, display.width() - 6, msg_part); - } - } - - if (ch_hist_count == 0) { - display.setColor(DisplayDriver::LIGHT); - display.drawTextCentered(display.width()/2, 32, "No messages yet"); - } - - // scroll hints - display.setColor(DisplayDriver::LIGHT); - if (_hist_scroll > 0) { - display.setCursor(display.width() - 6, HIST_START_Y + 1); - display.print("^"); - } - if (_hist_scroll + HIST_VISIBLE < ch_hist_count) { - display.setCursor(display.width() - 6, HIST_START_Y + HIST_VISIBLE * HIST_ITEM_H - 10); - display.print("v"); - } - - // small compose button (bottom-left, always bordered, inverted when selected) - bool compose_sel = (_hist_sel == -1); - const char* ctxt = "[+ send]"; - int ctw = display.getTextWidth(ctxt); - int cbx = 1, cby = 55; - if (compose_sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(cbx - 1, cby - 1, ctw + 4, 10); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - display.drawRect(cbx - 1, cby - 1, ctw + 4, 10); - } - display.setCursor(cbx + 1, cby); - display.print(ctxt); - display.setColor(DisplayDriver::LIGHT); - - } else if (_phase == KEYBOARD) { - return _kb.render(display); - - } else { // MSG_PICK - char title[24]; - if (_sending_to_channel) { - ChannelDetails ch; - the_mesh.getChannel(_sel_channel_idx, ch); - snprintf(title, sizeof(title), "%.23s", ch.name); - } else { - snprintf(title, sizeof(title), "TO:%.14s", _sel_contact.name); - } - display.drawTextCentered(display.width()/2, 0, title); - display.fillRect(0, 10, display.width(), 1); - - int total_msg_items = 1 + _active_msg_count; - for (int i = 0; i < VISIBLE && (_msg_scroll+i) < total_msg_items; i++) { - int idx = _msg_scroll + i; - bool sel = (idx == _msg_sel); - int y = START_Y + i * ITEM_H; - - if (sel) { - display.setColor(DisplayDriver::LIGHT); - display.fillRect(0, y - 1, display.width(), ITEM_H - 1); - display.setColor(DisplayDriver::DARK); - } else { - display.setColor(DisplayDriver::LIGHT); - } - display.setCursor(0, y); - display.print(sel ? ">" : " "); - - if (idx == 0) { - display.setCursor(8, y); - display.print("Custom message..."); - } else { - NodePrefs* p = _task->getNodePrefs(); - int slot = _active_msgs[idx - 1]; - const char* tmpl = p ? p->custom_msgs[slot] : ""; - display.drawTextEllipsized(8, y, display.width() - 10, tmpl); - } - } - display.setColor(DisplayDriver::LIGHT); - renderScrollHints(display, _msg_scroll, total_msg_items); - } - return 2000; - } - - bool handleInput(char c) override { - if (_phase == MODE_SELECT) { - if (c == KEY_CANCEL) { _task->gotoHomeScreen(); return true; } - if (c == KEY_UP && _mode_sel > 0) { _mode_sel--; return true; } - if (c == KEY_DOWN && _mode_sel < 2) { _mode_sel++; return true; } - if (c == KEY_ENTER) { - if (_mode_sel == 1) { - _phase = CHANNEL_PICK; - } else { - _room_mode = (_mode_sel == 2); - if (_room_mode) _task->clearRoomUnread(); - buildContactList(); - _contact_sel = _contact_scroll = 0; - _phase = CONTACT_PICK; - } - return true; - } - - } else if (_phase == CONTACT_PICK) { - // Context menu consumes all input while open - if (_ctx_menu.active) { - auto res = _ctx_menu.handleInput(c); - if (res == PopupMenu::SELECTED && _num_contacts > 0) { - ContactInfo ci; - if (the_mesh.getContactByIdx(_sorted[_contact_sel], ci)) { - if (_ctx_menu.selectedIndex() == 0) { - _task->clearDMUnread(ci.id.pub_key); - } else if (_ctx_menu.selectedIndex() == 1) { - uint8_t nstate = dmNotifState(ci.id.pub_key); - setDmNotifState(ci.id.pub_key, (nstate + 1) % 3); - _ctx_dirty = true; - } else { - uint8_t slot = dmMelodySlot(ci.id.pub_key); - setDmMelody(ci.id.pub_key, (slot + 1) % 3); - _ctx_dirty = true; - } - } - } - if (res != PopupMenu::NONE && _ctx_dirty) { - the_mesh.savePrefs(); _ctx_dirty = false; - } - return true; - } - if (c == KEY_CANCEL) { _room_mode = false; _phase = MODE_SELECT; return true; } - if (c == KEY_UP && _contact_sel > 0) { - _contact_sel--; - if (_contact_sel < _contact_scroll) _contact_scroll = _contact_sel; - return true; - } - if (c == KEY_DOWN && _contact_sel < _num_contacts - 1) { - _contact_sel++; - if (_contact_sel >= _contact_scroll + VISIBLE) _contact_scroll = _contact_sel - VISIBLE + 1; - return true; - } - if (c == KEY_ENTER && _num_contacts > 0) { - if (the_mesh.getContactByIdx(_sorted[_contact_sel], _sel_contact)) { - _task->clearDMUnread(_sel_contact.id.pub_key); - _dm_hist_sel = -1; - _dm_hist_scroll = 0; - _dm_fs.active = false; - _phase = DM_HIST; - } - return true; - } - if (c == KEY_CONTEXT_MENU && _num_contacts > 0 && !_room_mode) { - static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; - ContactInfo ci; - the_mesh.getContactByIdx(_sorted[_contact_sel], ci); - snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", - NOTIF_LABELS[dmNotifState(ci.id.pub_key)]); - { static const char* ML[] = { "global", "M1", "M2" }; - snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", - ML[dmMelodySlot(ci.id.pub_key)]); } - _ctx_menu.begin("Contact options", 3); - _ctx_menu.addItem("Mark as read"); - _ctx_menu.addItem(_ctx_notif_item); - _ctx_menu.addItem(_ctx_melody_item); - _ctx_dirty = false; - return true; - } - - } else if (_phase == CHANNEL_PICK) { - // Context menu consumes all input while open - if (_ctx_menu.active) { - auto res = _ctx_menu.handleInput(c); - if (res == PopupMenu::SELECTED && _num_channels > 0) { - uint8_t ch_idx = _channel_indices[_channel_sel]; - if (_ctx_menu.selectedIndex() == 0) { - _ch_unread[ch_idx] = 0; - } else if (_ctx_menu.selectedIndex() == 1) { - uint8_t nstate = chNotifState(ch_idx); - setChNotifState(ch_idx, (nstate + 1) % 3); - _ctx_dirty = true; - } else { - uint8_t slot = chNotifMelody(ch_idx); - setChNotifMelody(ch_idx, (slot + 1) % 3); - _ctx_dirty = true; - } - } - if (res != PopupMenu::NONE && _ctx_dirty) { - the_mesh.savePrefs(); _ctx_dirty = false; - } - return true; - } - if (c == KEY_CANCEL) { _phase = MODE_SELECT; return true; } - if (c == KEY_UP && _channel_sel > 0) { - _channel_sel--; - if (_channel_sel < _channel_scroll) _channel_scroll = _channel_sel; - return true; - } - if (c == KEY_DOWN && _channel_sel < _num_channels - 1) { - _channel_sel++; - if (_channel_sel >= _channel_scroll + VISIBLE) _channel_scroll = _channel_sel - VISIBLE + 1; - return true; - } - if (c == KEY_ENTER && _num_channels > 0) { - _sel_channel_idx = _channel_indices[_channel_sel]; - int hc = histCountForChannel(_sel_channel_idx); - _unread_at_entry = (int)_ch_unread[_sel_channel_idx]; - _hist_scroll = 0; - _hist_sel = hc > 0 ? 0 : -1; - _viewing_max_seen = _hist_sel >= 0 ? _hist_sel : 0; - _phase = CHANNEL_HIST; - updateChannelUnread(); - return true; - } - if (c == KEY_CONTEXT_MENU && _num_channels > 0) { - uint8_t ch_idx = _channel_indices[_channel_sel]; - static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" }; - snprintf(_ctx_notif_item, sizeof(_ctx_notif_item), "Notif: %s", - NOTIF_LABELS[chNotifState(ch_idx)]); - { static const char* ML[] = { "global", "M1", "M2" }; - snprintf(_ctx_melody_item, sizeof(_ctx_melody_item), "Melody: %s", - ML[chNotifMelody(ch_idx)]); } - _ctx_menu.begin("Channel options", 3); - _ctx_menu.addItem("Mark all read"); - _ctx_menu.addItem(_ctx_notif_item); - _ctx_menu.addItem(_ctx_melody_item); - _ctx_dirty = false; - return true; - } - - } else if (_phase == DM_HIST) { - int dm_count = dmHistCountForContact(_sel_contact.id.pub_key); - if (_dm_fs.active) { - auto res = _dm_fs.handleInput(c); - if (res == FullscreenMsgView::PREV) { - if (_dm_hist_sel < dm_count - 1) { _dm_hist_sel++; _dm_fs.scroll = 0; } - } else if (res == FullscreenMsgView::NEXT) { - if (_dm_hist_sel > 0) { _dm_hist_sel--; _dm_fs.scroll = 0; } - } else if (res == FullscreenMsgView::CLOSE) { - _dm_fs.active = false; - } - return true; - } - if (c == KEY_CANCEL) { _phase = CONTACT_PICK; return true; } - if (c == KEY_UP) { - if (_dm_hist_sel > 0) { - _dm_hist_sel--; - if (_dm_hist_sel < _dm_hist_scroll) _dm_hist_scroll = _dm_hist_sel; - } else if (_dm_hist_sel == 0) { - _dm_hist_sel = -1; - } - return true; - } - if (c == KEY_DOWN) { - if (_dm_hist_sel == -1 && dm_count > 0) { _dm_hist_sel = 0; _dm_hist_scroll = 0; } - else if (_dm_hist_sel >= 0 && _dm_hist_sel < dm_count - 1) { - _dm_hist_sel++; - if (_dm_hist_sel >= _dm_hist_scroll + HIST_VISIBLE) - _dm_hist_scroll = _dm_hist_sel - HIST_VISIBLE + 1; - } - return true; - } - if (c == KEY_ENTER) { - if (_dm_hist_sel >= 0) { - _dm_fs.begin(); - } else { - _sending_to_channel = false; - setupMsgPick(); - _phase = MSG_PICK; - } - return true; - } - - } else if (_phase == CHANNEL_HIST) { - int ch_hist_count = histCountForChannel(_sel_channel_idx); - if (_fs.active) { - auto res = _fs.handleInput(c); - if (res == FullscreenMsgView::PREV) { - if (_hist_sel < ch_hist_count - 1) { _hist_sel++; _fs.scroll = 0; updateChannelUnread(); } - } else if (res == FullscreenMsgView::NEXT) { - if (_hist_sel > 0) { _hist_sel--; _fs.scroll = 0; updateChannelUnread(); } - } else if (res == FullscreenMsgView::CLOSE) { - _fs.active = false; - } - return true; - } - if (c == KEY_CANCEL) { _phase = CHANNEL_PICK; return true; } - if (c == KEY_UP) { - if (_hist_sel > 0) { _hist_sel--; if (_hist_sel < _hist_scroll) _hist_scroll = _hist_sel; } - else if (_hist_sel == 0) _hist_sel = -1; - updateChannelUnread(); - return true; - } - if (c == KEY_DOWN) { - if (_hist_sel == -1 && ch_hist_count > 0) { _hist_sel = 0; _hist_scroll = 0; } - else if (_hist_sel >= 0 && _hist_sel < ch_hist_count - 1) { - _hist_sel++; - if (_hist_sel >= _hist_scroll + HIST_VISIBLE) _hist_scroll = _hist_sel - HIST_VISIBLE + 1; - } - updateChannelUnread(); - return true; - } - if (c == KEY_ENTER) { - if (_hist_sel >= 0) { - _fs.begin(); - updateChannelUnread(); - } else { - _sending_to_channel = true; - setupMsgPick(); - _phase = MSG_PICK; - } - return true; - } - - } else if (_phase == KEYBOARD) { - auto res = _kb.handleInput(c); - if (res == KeyboardWidget::CANCELLED) { - _phase = MSG_PICK; - } else if (res == KeyboardWidget::DONE) { - if (_kb.len > 0) { - char expanded[KB_MAX_LEN + 1]; - expandMsg(_kb.buf, expanded, sizeof(expanded)); - bool ok = sendText(expanded); - afterSend(ok, expanded); - } - } - return true; - - } else { // MSG_PICK - int total_msg_items = 1 + _active_msg_count; - if (c == KEY_CANCEL) { - _phase = _sending_to_channel ? CHANNEL_HIST : DM_HIST; - return true; - } - if (c == KEY_UP && _msg_sel > 0) { - _msg_sel--; - if (_msg_sel < _msg_scroll) _msg_scroll = _msg_sel; - return true; - } - if (c == KEY_DOWN && _msg_sel < total_msg_items - 1) { - _msg_sel++; - if (_msg_sel >= _msg_scroll + VISIBLE) _msg_scroll = _msg_sel - VISIBLE + 1; - return true; - } - if (c == KEY_ENTER) { - if (_msg_sel == 0) { - _kb.begin(); - kbAddSensorPlaceholders(_kb, &sensors); - _phase = KEYBOARD; - return true; - } - NodePrefs* p = _task->getNodePrefs(); - int slot = _active_msgs[_msg_sel - 1]; - const char* tmpl = p ? p->custom_msgs[slot] : "OK"; - char msg[80]; - expandMsg(tmpl, msg, sizeof(msg)); - bool ok = sendText(msg); - afterSend(ok, msg); - return true; - } - } - return false; - } -}; +#include "SettingsScreen.h" +#include "QuickMsgScreen.h" // ── Custom screens (separate files to ease upstream merges) ─────────────────── #include "RingtoneEditorScreen.h" From 9ff42a9296d6fe3ca9394703b4b011248609a8e0 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 09:55:51 +0200 Subject: [PATCH 13/28] =?UTF-8?q?feat:=20screen=20lock=20=E2=80=94=20hold?= =?UTF-8?q?=20Back=20+=203=C3=97Enter=20to=20lock/unlock?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Long press Back starts lock sequence; 3 Enter presses within 3s toggle lock - Locked screen wakes for 5 seconds on any key, showing clock and unlock hint - Incoming messages do not wake the display while locked - Auto Lock setting in Display section: locks automatically when screen turns off - Lock state persisted in UITask; NodePrefs gains auto_lock flag Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/NodePrefs.h | 1 + .../companion_radio/ui-new/SettingsScreen.h | 10 ++ examples/companion_radio/ui-new/UITask.cpp | 115 ++++++++++++++++-- examples/companion_radio/ui-new/UITask.h | 8 ++ 4 files changed, 126 insertions(+), 8 deletions(-) diff --git a/examples/companion_radio/NodePrefs.h b/examples/companion_radio/NodePrefs.h index 3f70e34e..e40f99a1 100644 --- a/examples/companion_radio/NodePrefs.h +++ b/examples/companion_radio/NodePrefs.h @@ -37,6 +37,7 @@ struct NodePrefs { // persisted to file uint8_t default_scope_key[16]; uint8_t display_brightness; // 0=min..4=max, default 2 (medium) uint16_t auto_off_secs; // display auto-off: 0=never, else seconds (default 15) + uint8_t auto_lock; // 0=disabled, 1=lock screen when display turns off int8_t tz_offset_hours; // timezone offset from UTC, -12..+14 (default 0) uint16_t low_batt_mv; // auto-shutdown threshold: 0=disabled, 3000-3500 mV uint8_t batt_display_mode; // 0=icon, 1=percent, 2=voltage diff --git a/examples/companion_radio/ui-new/SettingsScreen.h b/examples/companion_radio/ui-new/SettingsScreen.h index e5e8c6ed..ecd59b83 100644 --- a/examples/companion_radio/ui-new/SettingsScreen.h +++ b/examples/companion_radio/ui-new/SettingsScreen.h @@ -10,6 +10,7 @@ class SettingsScreen : public UIScreen { SECTION_DISPLAY, BRIGHTNESS, AUTO_OFF, + AUTO_LOCK, BATT_DISPLAY, CLOCK_SECONDS, // Sound section @@ -279,6 +280,10 @@ class SettingsScreen : public UIScreen { display.print("AutoOff"); display.setCursor(VAL_X, y); display.print(AUTO_OFF_LABELS[autoOffIndex()]); + } else if (item == AUTO_LOCK) { + display.print("AutoLock"); + display.setCursor(VAL_X, y); + display.print((p && p->auto_lock) ? "ON" : "OFF"); #if ENV_INCLUDE_GPS == 1 } else if (item == GPS_INTERVAL) { display.print("GPS upd"); @@ -452,6 +457,11 @@ public: if (left) idx = (idx + AUTO_OFF_COUNT - 1) % AUTO_OFF_COUNT; if (left || right) { p->auto_off_secs = AUTO_OFF_OPTS[idx]; _dirty = true; return true; } } + if (_selected == AUTO_LOCK && p && (left || right || enter)) { + p->auto_lock ^= 1; + _dirty = true; + return true; + } #if ENV_INCLUDE_GPS == 1 if (_selected == GPS_INTERVAL && p) { int idx = gpsIntervalIndex(); diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 0c5c1f50..0142e62c 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1049,7 +1049,7 @@ void UITask::newMsg(uint8_t path_len, const char* from_name, const char* text, i snprintf(alert_buf, sizeof(alert_buf), "Msg: %.20s", from_name); showAlert(alert_buf, 3000); - if (_display != NULL) { + if (_display != NULL && !_locked) { if (!_display->isOn() && !hasConnection()) { _display->turnOn(); } @@ -1157,6 +1157,13 @@ void UITask::loop() { ev = back_btn.check(); if (ev == BUTTON_EVENT_CLICK) { c = checkDisplayOn(KEY_CANCEL); + } else if (ev == BUTTON_EVENT_LONG_PRESS) { + if (_display != NULL && _display->isOn()) { + _lock_seq = 1; + _lock_seq_count = 0; + _lock_seq_ms = millis(); + _next_refresh = 0; + } } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { c = handleTripleClick(KEY_SELECT); } @@ -1199,10 +1206,48 @@ void UITask::loop() { } #endif - if (c != 0 && curr) { - curr->handleInput(c); - { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer - _next_refresh = 100; // trigger refresh + // Lock sequence timeout check + if (_lock_seq == 1 && millis() - _lock_seq_ms > 3000) { + _lock_seq = 0; + _lock_seq_count = 0; + } + + if (c != 0) { + // Intercept Enter presses when a lock/unlock sequence is active + if (_lock_seq == 1 && c == KEY_ENTER) { + _lock_seq_count++; + if (_lock_seq_count >= 3) { + _lock_seq = 0; + _lock_seq_count = 0; + _locked = !_locked; + if (_locked) { + _lock_wake_until = millis() + 2000; // brief "locked" display, then blank + } else { + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; + } + } + _next_refresh = 0; + } else if (_lock_seq == 1) { + // Non-Enter key cancels the sequence; process key normally below + _lock_seq = 0; + _lock_seq_count = 0; + if (!_locked && curr) { + curr->handleInput(c); + { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } + _next_refresh = 100; + } + } else if (!_locked && curr) { + curr->handleInput(c); + { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer + _next_refresh = 100; // trigger refresh + } else if (_locked) { + // Locked: eat all keys except unlock sequence (handled above) + // Any key extends the wake window if display is already on + if (_display != NULL && _display->isOn()) + _lock_wake_until = millis() + 5000; + _next_refresh = 0; + } } userLedHandler(); @@ -1221,7 +1266,49 @@ void UITask::loop() { if (curr) curr->poll(); if (_display != NULL && _display->isOn()) { - if (millis() >= _next_refresh && curr) { + if (_locked && millis() > _lock_wake_until) { + _display->turnOff(); + } else if (_locked && millis() >= _next_refresh) { + _display->startFrame(); + // Lock screen: clock + unlock hint popup + uint32_t unix_ts = rtc_clock.getCurrentTime(); + _display->setColor(DisplayDriver::LIGHT); + if (unix_ts < 1000000000UL) { + _display->setTextSize(1); + _display->drawTextCentered(_display->width() / 2, 20, "No time sync"); + } else { + int8_t tz = _node_prefs ? _node_prefs->tz_offset_hours : 0; + unix_ts += (int32_t)tz * 3600; + time_t t = (time_t)unix_ts; + struct tm* ti = gmtime(&t); + char buf[12]; + _display->setTextSize(2); + sprintf(buf, "%02d:%02d", ti->tm_hour, ti->tm_min); + _display->drawTextCentered(_display->width() / 2, 8, buf); + _display->setTextSize(1); + static const char* wd[] = {"Sun","Mon","Tue","Wed","Thu","Fri","Sat"}; + static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; + sprintf(buf, "%s %d %s", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon]); + _display->drawTextCentered(_display->width() / 2, 26, buf); + } + // Hint popup at bottom (like alert style) + _display->setTextSize(1); + const char* hint = _lock_seq == 1 + ? (_lock_seq_count == 0 ? "Enter x3 to unlock" : + _lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more...") + : "Hold Back + 3xEnter"; + int p = 3; + int hy = _display->height() - 13; + int hw = _display->getTextWidth(hint); + int hx = (_display->width() - hw) / 2; + _display->setColor(DisplayDriver::LIGHT); + _display->fillRect(hx - p, hy - p, hw + p*2, 8 + p*2); + _display->setColor(DisplayDriver::DARK); + _display->setCursor(hx, hy); + _display->print(hint); + _display->endFrame(); + _next_refresh = millis() + 1000; + } else if (!_locked && millis() >= _next_refresh && curr) { _display->startFrame(); int delay_millis = curr->render(*_display); if (millis() < _alert_expiry && curr == home) { // render alert only on home screen @@ -1240,11 +1327,15 @@ void UITask::loop() { _display->endFrame(); } #if AUTO_OFF_MILLIS > 0 - if (autoOffMillis() > 0 && millis() > _auto_off) { + if (!_locked && autoOffMillis() > 0 && millis() > _auto_off) { _display->turnOff(); #ifdef PIN_LED digitalWrite(PIN_LED, LOW); // turn off status LED with display to save power #endif + if (_node_prefs && _node_prefs->auto_lock) { + _locked = true; + _lock_wake_until = 0; + } } #endif } @@ -1283,9 +1374,17 @@ char UITask::checkDisplayOn(char c) { #ifdef PIN_LED digitalWrite(PIN_LED, LOW); // ensure LED is off when waking display (userLedHandler takes over) #endif + if (_locked) { + _lock_wake_until = millis() + 5000; + _next_refresh = 0; + return 0; // eat the waking key press + } c = 0; } - { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer + if (!_locked) { + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; // extend auto-off timer + } _next_refresh = 0; // trigger refresh } return c; diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index af8f87ad..5c6fadea 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -33,6 +33,11 @@ class UITask : public AbstractUITask { #endif unsigned long _next_refresh, _auto_off; NodePrefs* _node_prefs; + bool _locked; + unsigned long _lock_wake_until; // when to blank screen again after locked wake (5s) + int _lock_seq; // 0=idle, 1=active (long-Back pressed, awaiting Enters) + int _lock_seq_count; // Enter presses so far in current sequence + unsigned long _lock_seq_ms; // millis() when sequence started char _alert[80]; char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies unsigned long _alert_expiry; @@ -86,6 +91,9 @@ public: ui_started_at = 0; _batt_mv = 0; _msgcount = _room_unread = 0; + _locked = false; + _lock_wake_until = 0; + _lock_seq = 0; _lock_seq_count = 0; _lock_seq_ms = 0; _last_notif_ch_idx = -1; _last_notif_dm_valid = false; memset(_last_notif_dm_prefix, 0, sizeof(_last_notif_dm_prefix)); From ef0241b5f3b4fda780fdcb437123e8111b3876cd Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 10:04:28 +0200 Subject: [PATCH 14/28] feat: show two dashboard sensor values on lock screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuses dashboard_fields[0] and [1] from NodePrefs — left-aligned and right-aligned in one row under the clock, no labels to save space. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 78 ++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 0142e62c..bc932386 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1123,6 +1123,63 @@ bool UITask::isButtonPressed() const { #endif } +static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv) { + val[0] = '\0'; + switch (field) { + case DASH_NONE: return; + case DASH_BATT: + if (batt_mv > 0) snprintf(val, val_len, "%u.%02uV", batt_mv/1000, (batt_mv%1000)/10); + else strcpy(val, "--"); + return; + case DASH_NODES: + snprintf(val, val_len, "%d nodes", the_mesh.getNumContacts()); + return; +#if ENV_INCLUDE_GPS == 1 + case DASH_GPS: { + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) + snprintf(val, val_len, "%.2f %.2f", + loc->getLatitude()/1000000.0f, loc->getLongitude()/1000000.0f); + else strcpy(val, "no fix"); + return; + } +#endif + default: break; + } + // LPP sensor fields: query sensors into a local buffer + uint8_t lpp_type = 0; + switch (field) { + case DASH_TEMP: lpp_type = LPP_TEMPERATURE; break; + case DASH_HUM: lpp_type = LPP_RELATIVE_HUMIDITY; break; + case DASH_PRES: lpp_type = LPP_BAROMETRIC_PRESSURE; break; + case DASH_ALT: lpp_type = LPP_ALTITUDE; break; + case DASH_LUX: lpp_type = LPP_LUMINOSITY; break; + case DASH_CO2: lpp_type = LPP_CONCENTRATION; break; + } + if (lpp_type) { + CayenneLPP lpp(200); + lpp.reset(); + sensors.querySensors(0xFF, lpp); + LPPReader r(lpp.getBuffer(), lpp.getSize()); + uint8_t ch, type; + while (r.readHeader(ch, type)) { + if (type == lpp_type) { + float v; + switch (lpp_type) { + case LPP_TEMPERATURE: r.readTemperature(v); snprintf(val, val_len, "%.1f\xf8""C", v); return; + case LPP_RELATIVE_HUMIDITY: r.readRelativeHumidity(v); snprintf(val, val_len, "%.0f%%", v); return; + case LPP_BAROMETRIC_PRESSURE: r.readPressure(v); snprintf(val, val_len, "%.0fhPa", v); return; + case LPP_ALTITUDE: r.readAltitude(v); snprintf(val, val_len, "%.0fm", v); return; + case LPP_LUMINOSITY: r.readLuminosity(v); snprintf(val, val_len, "%.0flux", v); return; + case LPP_CONCENTRATION: r.readConcentration(v); snprintf(val, val_len, "%.0fppm", v); return; + } + } + r.skipData(type); + } + strcpy(val, "--"); + } +} + void UITask::loop() { char c = 0; #if UI_HAS_JOYSTICK @@ -1290,6 +1347,27 @@ void UITask::loop() { static const char* mo[] = {"Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"}; sprintf(buf, "%s %d %s", wd[ti->tm_wday], ti->tm_mday, mo[ti->tm_mon]); _display->drawTextCentered(_display->width() / 2, 26, buf); + + // Two sensor values side by side (dashboard_fields[0] and [1]) + if (_node_prefs) { + char v0[20] = "", v1[20] = ""; + formatDashVal(_node_prefs->dashboard_fields[0], v0, sizeof(v0), _batt_mv); + formatDashVal(_node_prefs->dashboard_fields[1], v1, sizeof(v1), _batt_mv); + if (v0[0] || v1[0]) { + _display->setTextSize(1); + _display->setColor(DisplayDriver::LIGHT); + if (v0[0] && v1[0]) { + _display->setCursor(0, 37); + _display->print(v0); + int vw = _display->getTextWidth(v1); + _display->setCursor(_display->width() - vw, 37); + _display->print(v1); + } else { + const char* sv = v0[0] ? v0 : v1; + _display->drawTextCentered(_display->width() / 2, 37, sv); + } + } + } } // Hint popup at bottom (like alert style) _display->setTextSize(1); From 43f3621fdc817542da5cfa35d06f3dc3f5bd4880 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 10:09:54 +0200 Subject: [PATCH 15/28] fix: replace long-press lock trigger with back_btn.isPressed() check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Long press detection on back_btn was unreliable — the release generated a spurious CLICK that cancelled the sequence. Now the Enter click handler checks back_btn.isPressed() directly: holding Back and clicking Enter 3× within 3 seconds locks/unlocks the screen. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 71 +++++++++------------- examples/companion_radio/ui-new/UITask.h | 7 +-- 2 files changed, 31 insertions(+), 47 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index bc932386..35ab5abb 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1185,7 +1185,26 @@ void UITask::loop() { #if UI_HAS_JOYSTICK int ev = user_btn.check(); if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(KEY_ENTER); + if (back_btn.isPressed()) { + // Enter clicked while Back is held — lock/unlock sequence + if (millis() - _lock_seq_ms > 3000) _lock_seq_count = 0; // timeout reset + _lock_seq_count++; + _lock_seq_ms = millis(); + if (_lock_seq_count >= 3) { + _lock_seq_count = 0; + _locked = !_locked; + if (_locked) { + _lock_wake_until = millis() + 2000; + } else { + uint32_t aoff = autoOffMillis(); + if (aoff > 0) _auto_off = millis() + aoff; + } + _next_refresh = 0; + } + // eat the Enter — don't pass to curr + } else { + c = checkDisplayOn(KEY_ENTER); + } } else if (ev == BUTTON_EVENT_LONG_PRESS) { c = handleLongPress(KEY_ENTER); // REVISIT: could be mapped to different key code } @@ -1213,13 +1232,11 @@ void UITask::loop() { } ev = back_btn.check(); if (ev == BUTTON_EVENT_CLICK) { - c = checkDisplayOn(KEY_CANCEL); - } else if (ev == BUTTON_EVENT_LONG_PRESS) { - if (_display != NULL && _display->isOn()) { - _lock_seq = 1; + if (_lock_seq_count > 0) { + // Back released mid-sequence — cancel sequence, eat the click _lock_seq_count = 0; - _lock_seq_ms = millis(); - _next_refresh = 0; + } else { + c = checkDisplayOn(KEY_CANCEL); } } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { c = handleTripleClick(KEY_SELECT); @@ -1263,43 +1280,13 @@ void UITask::loop() { } #endif - // Lock sequence timeout check - if (_lock_seq == 1 && millis() - _lock_seq_ms > 3000) { - _lock_seq = 0; - _lock_seq_count = 0; - } - if (c != 0) { - // Intercept Enter presses when a lock/unlock sequence is active - if (_lock_seq == 1 && c == KEY_ENTER) { - _lock_seq_count++; - if (_lock_seq_count >= 3) { - _lock_seq = 0; - _lock_seq_count = 0; - _locked = !_locked; - if (_locked) { - _lock_wake_until = millis() + 2000; // brief "locked" display, then blank - } else { - uint32_t aoff = autoOffMillis(); - if (aoff > 0) _auto_off = millis() + aoff; - } - } - _next_refresh = 0; - } else if (_lock_seq == 1) { - // Non-Enter key cancels the sequence; process key normally below - _lock_seq = 0; - _lock_seq_count = 0; - if (!_locked && curr) { - curr->handleInput(c); - { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } - _next_refresh = 100; - } - } else if (!_locked && curr) { + if (!_locked && curr) { curr->handleInput(c); { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer _next_refresh = 100; // trigger refresh } else if (_locked) { - // Locked: eat all keys except unlock sequence (handled above) + // Locked: eat all keys (unlock sequence handled at button-event level above) // Any key extends the wake window if display is already on if (_display != NULL && _display->isOn()) _lock_wake_until = millis() + 5000; @@ -1371,10 +1358,8 @@ void UITask::loop() { } // Hint popup at bottom (like alert style) _display->setTextSize(1); - const char* hint = _lock_seq == 1 - ? (_lock_seq_count == 0 ? "Enter x3 to unlock" : - _lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more...") - : "Hold Back + 3xEnter"; + const char* hint = _lock_seq_count == 0 ? "Hold Back + 3xEnter" : + _lock_seq_count == 1 ? "Enter x2 more..." : "Enter x1 more..."; int p = 3; int hy = _display->height() - 13; int hw = _display->getTextWidth(hint); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index 5c6fadea..baa6f0b0 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -35,9 +35,8 @@ class UITask : public AbstractUITask { NodePrefs* _node_prefs; bool _locked; unsigned long _lock_wake_until; // when to blank screen again after locked wake (5s) - int _lock_seq; // 0=idle, 1=active (long-Back pressed, awaiting Enters) - int _lock_seq_count; // Enter presses so far in current sequence - unsigned long _lock_seq_ms; // millis() when sequence started + int _lock_seq_count; // Enter presses while Back held (lock/unlock sequence) + unsigned long _lock_seq_ms; // millis() of last lock-sequence press (for timeout) char _alert[80]; char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies unsigned long _alert_expiry; @@ -93,7 +92,7 @@ public: _msgcount = _room_unread = 0; _locked = false; _lock_wake_until = 0; - _lock_seq = 0; _lock_seq_count = 0; _lock_seq_ms = 0; + _lock_seq_count = 0; _lock_seq_ms = 0; _last_notif_ch_idx = -1; _last_notif_dm_valid = false; memset(_last_notif_dm_prefix, 0, sizeof(_last_notif_dm_prefix)); From 20e88443b447d40d49cd2bf4d15b6ccd2477732c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 11:17:59 +0200 Subject: [PATCH 16/28] =?UTF-8?q?fix:=20polish=20lock=20screen=20sequence?= =?UTF-8?q?=20=E2=80=94=20hints=20update=20instantly,=20screen=20stays=20o?= =?UTF-8?q?n,=20triple-Back=20blocked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Turn display on at first Back+Enter press so hints are visible even when screen was dark - Extend _lock_wake_until on every sequence step to prevent screen blanking mid-press - Set _next_refresh=0 on every Enter press (not just on completion) for immediate hint redraw - Add _lock_seq_used flag to suppress the Back CLICK that fires on release after unlock - Block triple-Back (buzzer toggle) when screen is locked Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 15 +++++++++++---- examples/companion_radio/ui-new/UITask.h | 3 ++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 35ab5abb..40f7d775 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1187,19 +1187,25 @@ void UITask::loop() { if (ev == BUTTON_EVENT_CLICK) { if (back_btn.isPressed()) { // Enter clicked while Back is held — lock/unlock sequence + if (_display && !_display->isOn()) { + _display->turnOn(); // turn on display so hints are visible + } + _lock_wake_until = millis() + 5000; // keep display on during sequence if (millis() - _lock_seq_ms > 3000) _lock_seq_count = 0; // timeout reset _lock_seq_count++; _lock_seq_ms = millis(); + _next_refresh = 0; // update hint immediately on each press if (_lock_seq_count >= 3) { _lock_seq_count = 0; + _lock_seq_used = true; // suppress Back release click _locked = !_locked; if (_locked) { _lock_wake_until = millis() + 2000; } else { + if (_display && !_display->isOn()) _display->turnOn(); uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } - _next_refresh = 0; } // eat the Enter — don't pass to curr } else { @@ -1232,14 +1238,15 @@ void UITask::loop() { } ev = back_btn.check(); if (ev == BUTTON_EVENT_CLICK) { - if (_lock_seq_count > 0) { - // Back released mid-sequence — cancel sequence, eat the click + if (_lock_seq_count > 0 || _lock_seq_used) { + // Back released mid-sequence or after completing it — cancel/suppress _lock_seq_count = 0; + _lock_seq_used = false; } else { c = checkDisplayOn(KEY_CANCEL); } } else if (ev == BUTTON_EVENT_TRIPLE_CLICK) { - c = handleTripleClick(KEY_SELECT); + if (!_locked) c = handleTripleClick(KEY_SELECT); } #elif defined(PIN_USER_BTN) int ev = user_btn.check(); diff --git a/examples/companion_radio/ui-new/UITask.h b/examples/companion_radio/ui-new/UITask.h index baa6f0b0..29366166 100644 --- a/examples/companion_radio/ui-new/UITask.h +++ b/examples/companion_radio/ui-new/UITask.h @@ -37,6 +37,7 @@ class UITask : public AbstractUITask { unsigned long _lock_wake_until; // when to blank screen again after locked wake (5s) int _lock_seq_count; // Enter presses while Back held (lock/unlock sequence) unsigned long _lock_seq_ms; // millis() of last lock-sequence press (for timeout) + bool _lock_seq_used; // true = suppress next back_btn CLICK (post-sequence release) char _alert[80]; char _notif_mel_buf[220]; // persistent RTTTL buffer for custom notification melodies unsigned long _alert_expiry; @@ -92,7 +93,7 @@ public: _msgcount = _room_unread = 0; _locked = false; _lock_wake_until = 0; - _lock_seq_count = 0; _lock_seq_ms = 0; + _lock_seq_count = 0; _lock_seq_ms = 0; _lock_seq_used = false; _last_notif_ch_idx = -1; _last_notif_dm_valid = false; memset(_last_notif_dm_prefix, 0, sizeof(_last_notif_dm_prefix)); From 6492fd490ef0508482f8e96302558420179014d4 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 12:01:51 +0200 Subject: [PATCH 17/28] feat: add Czech and Slovak diacritic transliteration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds háček and other Czech/Slovak characters (č š ž ř ě ů ď ť ň ľ ĺ ŕ) to the UTF-8 transliteration table so they display as ASCII letters instead of █ blocks. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/DisplayDriver.h | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 229a907a..1bea52fd 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -54,6 +54,19 @@ public: case 0x015B: return 's'; case 0x015A: return 'S'; // ś Ś case 0x017A: return 'z'; case 0x0179: return 'Z'; // ź Ź case 0x017C: return 'z'; case 0x017B: return 'Z'; // ż Ż + // Czech/Slovak + case 0x010D: return 'c'; case 0x010C: return 'C'; // č Č + case 0x0161: return 's'; case 0x0160: return 'S'; // š Š + case 0x017E: return 'z'; case 0x017D: return 'Z'; // ž Ž + case 0x0159: return 'r'; case 0x0158: return 'R'; // ř Ř + case 0x011B: return 'e'; case 0x011A: return 'E'; // ě Ě + case 0x016F: return 'u'; case 0x016E: return 'U'; // ů Ů + case 0x010F: return 'd'; case 0x010E: return 'D'; // ď Ď + case 0x0165: return 't'; case 0x0164: return 'T'; // ť Ť + case 0x0148: return 'n'; case 0x0147: return 'N'; // ň Ň + case 0x013E: return 'l'; case 0x013D: return 'L'; // ľ Ľ + case 0x013A: return 'l'; case 0x0139: return 'L'; // ĺ Ĺ + case 0x0155: return 'r'; case 0x0154: return 'R'; // ŕ Ŕ // German case 0x00E4: return 'a'; case 0x00C4: return 'A'; // ä Ä case 0x00F6: return 'o'; case 0x00D6: return 'O'; // ö Ö From b0552e246a6deee47b41cb10615224a6a94c913b Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 12:08:20 +0200 Subject: [PATCH 18/28] feat: extend UTF-8 transliteration to cover all major European languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Scandinavian (å ø æ), Hungarian (ő ű), Romanian (ă ș ț), Croatian (đ), Turkish (ğ ş ı), and Baltic/Lithuanian/Latvian (ā ē ī ū ģ ķ ļ ņ ŗ ų ė į) diacritics. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/DisplayDriver.h | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 1bea52fd..32ef5c71 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -72,6 +72,36 @@ public: case 0x00F6: return 'o'; case 0x00D6: return 'O'; // ö Ö case 0x00FC: return 'u'; case 0x00DC: return 'U'; // ü Ü case 0x00DF: return 's'; // ß + // Scandinavian + case 0x00E5: return 'a'; case 0x00C5: return 'A'; // å Å + case 0x00F8: return 'o'; case 0x00D8: return 'O'; // ø Ø + case 0x00E6: return 'a'; case 0x00C6: return 'A'; // æ Æ + // Hungarian + case 0x0151: return 'o'; case 0x0150: return 'O'; // ő Ő + case 0x0171: return 'u'; case 0x0170: return 'U'; // ű Ű + // Romanian + case 0x0103: return 'a'; case 0x0102: return 'A'; // ă Ă + case 0x0219: return 's'; case 0x0218: return 'S'; // ș Ș + case 0x021B: return 't'; case 0x021A: return 'T'; // ț Ț + // Croatian + case 0x0111: return 'd'; case 0x0110: return 'D'; // đ Đ + // Turkish + case 0x011F: return 'g'; case 0x011E: return 'G'; // ğ Ğ + case 0x015F: return 's'; case 0x015E: return 'S'; // ş Ş + case 0x0131: return 'i'; // ı + // Baltic (Lithuanian/Latvian) + case 0x0101: return 'a'; case 0x0100: return 'A'; // ā Ā + case 0x0113: return 'e'; case 0x0112: return 'E'; // ē Ē + case 0x012B: return 'i'; case 0x012A: return 'I'; // ī Ī + case 0x016B: return 'u'; case 0x016A: return 'U'; // ū Ū + case 0x0123: return 'g'; case 0x0122: return 'G'; // ģ Ģ + case 0x0137: return 'k'; case 0x0136: return 'K'; // ķ Ķ + case 0x013C: return 'l'; case 0x013B: return 'L'; // ļ Ļ + case 0x0146: return 'n'; case 0x0145: return 'N'; // ņ Ņ + case 0x0157: return 'r'; case 0x0156: return 'R'; // ŗ Ŗ + case 0x0173: return 'u'; case 0x0172: return 'U'; // ų Ų + case 0x0117: return 'e'; case 0x0116: return 'E'; // ė Ė + case 0x012F: return 'i'; case 0x012E: return 'I'; // į Į // French/Spanish/Portuguese common accents case 0x00E0: case 0x00E1: case 0x00E2: case 0x00E3: return 'a'; case 0x00C0: case 0x00C1: case 0x00C2: case 0x00C3: return 'A'; From 2bc6dae78eec137233e80bcc753d9ede4473f0cf Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 12:11:42 +0200 Subject: [PATCH 19/28] feat: add Icelandic and Romanian legacy cedilla transliteration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ð/Ð (eth), þ/Þ (thorn) for Icelandic, and ţ/Ţ (t with cedilla) for legacy-encoded Romanian text. Co-Authored-By: Claude Sonnet 4.6 --- src/helpers/ui/DisplayDriver.h | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/helpers/ui/DisplayDriver.h b/src/helpers/ui/DisplayDriver.h index 32ef5c71..d6cf198b 100644 --- a/src/helpers/ui/DisplayDriver.h +++ b/src/helpers/ui/DisplayDriver.h @@ -85,6 +85,11 @@ public: case 0x021B: return 't'; case 0x021A: return 'T'; // ț Ț // Croatian case 0x0111: return 'd'; case 0x0110: return 'D'; // đ Đ + // Icelandic + case 0x00F0: return 'd'; case 0x00D0: return 'D'; // ð Ð (eth) + case 0x00FE: return 't'; case 0x00DE: return 'T'; // þ Þ (thorn) + // Romanian cedilla variants (legacy encoding) + case 0x0163: return 't'; case 0x0162: return 'T'; // ţ Ţ // Turkish case 0x011F: return 'g'; case 0x011E: return 'G'; // ğ Ğ case 0x015F: return 's'; case 0x015E: return 'S'; // ş Ş From 3fb0a55aef69d64757db9634570b5a03202a25f6 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 12:17:02 +0200 Subject: [PATCH 20/28] fix: auto-reset _lock_seq_used after 5s in case Back release event is missed Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 40f7d775..7ce0e4d1 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1236,6 +1236,9 @@ void UITask::loop() { } else if (ev == BUTTON_EVENT_LONG_PRESS) { c = handleLongPress(KEY_RIGHT); } + if (_lock_seq_used && millis() - _lock_seq_ms > 5000) { + _lock_seq_used = false; // safety reset if Back release event was missed + } ev = back_btn.check(); if (ev == BUTTON_EVENT_CLICK) { if (_lock_seq_count > 0 || _lock_seq_used) { From a2856b7a4838d7701b79f0427b0a700084229898 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 14:30:53 +0200 Subject: [PATCH 21/28] fix: lock screen wake window no longer extends on repeated key presses Previously every key press while locked reset _lock_wake_until to now+5s, so multiple presses (or spurious button events) kept extending the window. Now the 5s timer is set only when the display first wakes from dark. The unlock sequence still extends the window on each Back+Enter step. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 7ce0e4d1..9fa0f97f 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1296,10 +1296,7 @@ void UITask::loop() { { uint32_t aoff = autoOffMillis(); if (aoff > 0) _auto_off = millis() + aoff; } // extend auto-off timer _next_refresh = 100; // trigger refresh } else if (_locked) { - // Locked: eat all keys (unlock sequence handled at button-event level above) - // Any key extends the wake window if display is already on - if (_display != NULL && _display->isOn()) - _lock_wake_until = millis() + 5000; + // Locked: eat all keys — wake window is set only when display first turns on _next_refresh = 0; } } From 360d5d10f7a644afa32d07bf675a135a7641ffae Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 14:33:58 +0200 Subject: [PATCH 22/28] feat: add 30s minimum interval option to auto-advert screen Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/AutoAdvertScreen.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/companion_radio/ui-new/AutoAdvertScreen.h b/examples/companion_radio/ui-new/AutoAdvertScreen.h index c4c1dac6..114d0d02 100644 --- a/examples/companion_radio/ui-new/AutoAdvertScreen.h +++ b/examples/companion_radio/ui-new/AutoAdvertScreen.h @@ -7,7 +7,7 @@ class AutoAdvertScreen : public UIScreen { NodePrefs* _prefs; bool _dirty; - static const int OPT_COUNT = 7; + static const int OPT_COUNT = 8; static const uint32_t OPTS[OPT_COUNT]; static const char* OPT_LABELS[OPT_COUNT]; @@ -64,5 +64,5 @@ public: } }; -const uint32_t AutoAdvertScreen::OPTS[AutoAdvertScreen::OPT_COUNT] = { 0, 60, 120, 300, 600, 1800, 3600 }; -const char* AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::OPT_COUNT] = { "off", "1min", "2min", "5min", "10min", "30min", "1h" }; +const uint32_t AutoAdvertScreen::OPTS[AutoAdvertScreen::OPT_COUNT] = { 0, 30, 60, 120, 300, 600, 1800, 3600 }; +const char* AutoAdvertScreen::OPT_LABELS[AutoAdvertScreen::OPT_COUNT] = { "off", "30s", "1min", "2min", "5min", "10min", "30min", "1h" }; From 28d69f88f4faa62b5d2bb6aee1dee54d458148d2 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 18:51:48 +0200 Subject: [PATCH 23/28] Add Discord discussion link to README Added a link to the discussion channel on Discord. --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2d7709de..ab78069f 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ This branch extends the official MeshCore companion radio firmware for the **Seeed Wio Tracker L1**. +Join the discussion: https://discord.com/channels/1495203904898728149/1505294337884553447 + ## New Features ### Messages Screen From 44122565f6f276d6a4864086a4914cce9081677e Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 18:32:49 +0200 Subject: [PATCH 24/28] perf: query sensors once for both lock screen dashboard fields Previously each LPP sensor field called querySensors() separately. Now a single querySensors() call fills a shared buffer used by both fields. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/UITask.cpp | 25 +++++++++++++++------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index 9fa0f97f..456ebc1d 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -1123,7 +1123,8 @@ bool UITask::isButtonPressed() const { #endif } -static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv) { +static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_mv, + CayenneLPP* lpp = nullptr) { val[0] = '\0'; switch (field) { case DASH_NONE: return; @@ -1146,7 +1147,7 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m #endif default: break; } - // LPP sensor fields: query sensors into a local buffer + // LPP sensor fields uint8_t lpp_type = 0; switch (field) { case DASH_TEMP: lpp_type = LPP_TEMPERATURE; break; @@ -1157,10 +1158,9 @@ static void formatDashVal(uint8_t field, char* val, int val_len, uint16_t batt_m case DASH_CO2: lpp_type = LPP_CONCENTRATION; break; } if (lpp_type) { - CayenneLPP lpp(200); - lpp.reset(); - sensors.querySensors(0xFF, lpp); - LPPReader r(lpp.getBuffer(), lpp.getSize()); + CayenneLPP local_lpp(200); + if (!lpp) { local_lpp.reset(); sensors.querySensors(0xFF, local_lpp); lpp = &local_lpp; } + LPPReader r(lpp->getBuffer(), lpp->getSize()); uint8_t ch, type; while (r.readHeader(ch, type)) { if (type == lpp_type) { @@ -1345,8 +1345,17 @@ void UITask::loop() { // Two sensor values side by side (dashboard_fields[0] and [1]) if (_node_prefs) { char v0[20] = "", v1[20] = ""; - formatDashVal(_node_prefs->dashboard_fields[0], v0, sizeof(v0), _batt_mv); - formatDashVal(_node_prefs->dashboard_fields[1], v1, sizeof(v1), _batt_mv); + CayenneLPP shared_lpp(200); + CayenneLPP* lpp_ptr = nullptr; + uint8_t f0 = _node_prefs->dashboard_fields[0], f1 = _node_prefs->dashboard_fields[1]; + auto isLPP = [](uint8_t f) { + return f==DASH_TEMP||f==DASH_HUM||f==DASH_PRES||f==DASH_ALT||f==DASH_LUX||f==DASH_CO2; + }; + if (isLPP(f0) || isLPP(f1)) { + shared_lpp.reset(); sensors.querySensors(0xFF, shared_lpp); lpp_ptr = &shared_lpp; + } + formatDashVal(f0, v0, sizeof(v0), _batt_mv, lpp_ptr); + formatDashVal(f1, v1, sizeof(v1), _batt_mv, lpp_ptr); if (v0[0] || v1[0]) { _display->setTextSize(1); _display->setColor(DisplayDriver::LIGHT); From a5a32530f059b656fc63baa35f125884f40b17c3 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sun, 17 May 2026 18:55:21 +0200 Subject: [PATCH 25/28] Update Discord link in README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ab78069f..907d8a92 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ This branch extends the official MeshCore companion radio firmware for the **Seeed Wio Tracker L1**. -Join the discussion: https://discord.com/channels/1495203904898728149/1505294337884553447 +Join the discussion on offical MeshCore discord: https://discord.gg/sdhYArU2jr ## New Features From 4df97ae5ed568b01ae1328d964b1580ce93f4825 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 18 May 2026 09:39:10 +0200 Subject: [PATCH 26/28] fix: persist auto_lock setting across reboots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auto_lock was defined in NodePrefs but missing from both loadPrefsInt and savePrefs in DataStore.cpp. Added at the end of the serialization chain (inside if(file.available())) for backwards compatibility with existing saved files — old files default to auto_lock=0 (disabled). Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/DataStore.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index e3e8657c..d9e0d10c 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -283,6 +283,9 @@ void DataStore::loadPrefsInt(const char *filename, NodePrefs& _prefs, double& no file.read((uint8_t *)&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2)); if (file.available()) { file.read((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody)); + if (file.available()) { + file.read((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock)); + } } } } @@ -371,6 +374,7 @@ void DataStore::savePrefs(const NodePrefs& _prefs, double node_lat, double node_ file.write((uint8_t *)&_prefs.ch_notif_melody_set, sizeof(_prefs.ch_notif_melody_set)); file.write((uint8_t *)&_prefs.ch_notif_melody_2, sizeof(_prefs.ch_notif_melody_2)); file.write((uint8_t *)_prefs.dm_melody, sizeof(_prefs.dm_melody)); + file.write((uint8_t *)&_prefs.auto_lock, sizeof(_prefs.auto_lock)); file.close(); } From 584ef33b3e610b61788f68d4feb84bb6cc7ccd4c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 18 May 2026 09:41:56 +0200 Subject: [PATCH 27/28] fix: channel bot respects bot_enabled flag tryBotReplyChannel checked bot_channel_enabled but not bot_enabled, so disabling the bot via the main Enable toggle had no effect on channel replies. Added bot_enabled to the guard condition. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/MyMeshBot.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/MyMeshBot.h b/examples/companion_radio/MyMeshBot.h index 5c892164..d75af84e 100644 --- a/examples/companion_radio/MyMeshBot.h +++ b/examples/companion_radio/MyMeshBot.h @@ -35,7 +35,7 @@ void MyMesh::tryBotReplyDM(const ContactInfo& from, const char* text) { } void MyMesh::tryBotReplyChannel(uint8_t channel_idx, const char* text) { - if (!(_prefs.bot_channel_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_ch[0] && + if (!(_prefs.bot_enabled && _prefs.bot_channel_enabled && _prefs.bot_trigger[0] && _prefs.bot_reply_ch[0] && channel_idx == _prefs.bot_channel_idx && millis() - _bot_last_ch_reply_ms > 10000UL)) return; From 9b61bc30280c6647d48d934feb34045bbcb8347e Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Mon, 18 May 2026 09:53:36 +0200 Subject: [PATCH 28/28] fix: increase quick-message expansion buffer from 80 to 140 bytes custom_msgs templates are up to 140 chars; the 80-byte msg[] buffer silently truncated any message longer than 79 characters before sending. Co-Authored-By: Claude Sonnet 4.6 --- examples/companion_radio/ui-new/QuickMsgScreen.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/companion_radio/ui-new/QuickMsgScreen.h b/examples/companion_radio/ui-new/QuickMsgScreen.h index a5b4b2ba..e2230605 100644 --- a/examples/companion_radio/ui-new/QuickMsgScreen.h +++ b/examples/companion_radio/ui-new/QuickMsgScreen.h @@ -1059,7 +1059,7 @@ public: NodePrefs* p = _task->getNodePrefs(); int slot = _active_msgs[_msg_sel - 1]; const char* tmpl = p ? p->custom_msgs[slot] : "OK"; - char msg[80]; + char msg[140]; expandMsg(tmpl, msg, sizeof(msg)); bool ok = sendText(msg); afterSend(ok, msg);