mirror of
https://github.com/MarekZegare4/MeshCore-Solo.git
synced 2026-07-26 14:58:12 +00:00
feat(ui): on-device channel management, remote admin tool, per-language keyboards
Messages: - Add/edit/delete channels on-device (new ChannelsView, owned by the renamed MessagesScreen — was QuickMsgScreen, whose name no longer matched its scope). Channel secret entry supports a typed passphrase (SHA-256'd, same primitive the library already uses for the routing hash) or a raw 32-hex-char key. - MyMesh::setChannelLocal() factors out the setChannel/saveChannels/ onChannelRemoved sequence previously duplicated across the two CMD_SET_CHANNEL branches, shared now by the BLE and on-device paths. Tools > Admin (new): - Log into a repeater/room server's admin account and send CLI commands, the on-device equivalent of the app's repeater-admin feature. - Commands are organised into category tabs (System/Radio/Routing/Actions) with common get/set fields (name, radio profile, tx power, repeat, advert intervals, ...) plus a free-text "Custom command..." fallback for anything else. A field row fetches the current value, opens it pre-filled for editing, and sends the change — falling back to a blank editor if the fetch fails or times out. - The admin password persists and self-heals exactly like room logins in Messages: saved on a confirmed admin-level login, forgotten on a failed one, left alone if merely under-privileged. - New MyMesh::sendAdminCommand()/AbstractUITask::onAdminReply() plumbing so a reply reaches the UI without touching the existing BLE/app CLI-terminal path (queueMessage's should_display gate is untouched). Shared TabBar.h extracted from NearbyScreen/BotScreen's independently duplicated tab-carousel rendering (now a third consumer via Admin) — also fixes neighbouring tabs vanishing outright when they didn't fully fit; they now truncate with an ellipsis instead. Keyboard: the combined "Ext.Latin" alphabet split into 8 separate, linguistically complete per-language keyboards (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic), and fixed an OLED-only bug where tall accented glyphs overlapped the keyboard's separator line (SH1106's Lemon-font ascent constant was 2-3px short for them). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,9 @@ public:
|
||||
// pub_key is the contact's key prefix (>=4 bytes valid); permissions is the
|
||||
// room/repeater ACL byte (only meaningful when success is true).
|
||||
virtual void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) { (void)pub_key; (void)success; (void)permissions; }
|
||||
// Text reply to an on-device-UI-triggered MyMesh::sendAdminCommand() arrived
|
||||
// (see AdminScreen). pub_key is the contact's key prefix (>=4 bytes valid).
|
||||
virtual void onAdminReply(const uint8_t* pub_key, const char* text) { (void)pub_key; (void)text; }
|
||||
// True only when a BLE central is actually bonded/connected. On a dual
|
||||
// (BLE+USB) interface hasConnection() is always true (USB counts), so use
|
||||
// this for BLE-specific UI like the pairing-PIN prompt.
|
||||
|
||||
@@ -690,6 +690,14 @@ void MyMesh::onCommandDataRecv(const ContactInfo &from, mesh::Packet *pkt, uint3
|
||||
const char *text) {
|
||||
markConnectionActive(from); // in case this is from a server, and we have a connection
|
||||
queueMessage(from, TXT_TYPE_CLI_DATA, pkt, sender_timestamp, NULL, 0, text);
|
||||
// If the on-device Admin screen sent this command (not the BLE/USB app's CLI
|
||||
// terminal), also hand the reply straight to the UI -- queueMessage() above
|
||||
// never displays TXT_TYPE_CLI_DATA on-device (see should_display), since that
|
||||
// path also serves the app's terminal, which must keep working unaffected.
|
||||
if (_ui && ui_pending_admin_reply && memcmp(&ui_pending_admin_reply, from.id.pub_key, 4) == 0) {
|
||||
ui_pending_admin_reply = 0;
|
||||
_ui->onAdminReply(from.id.pub_key, text);
|
||||
}
|
||||
}
|
||||
|
||||
void MyMesh::onSignedMessageRecv(const ContactInfo &from, mesh::Packet *pkt, uint32_t sender_timestamp,
|
||||
@@ -1749,6 +1757,20 @@ static bool isAllZero(const uint8_t* buf, size_t n) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Shared by the BLE/USB CMD_SET_CHANNEL handler below and the on-device
|
||||
// Channels add/edit/delete UI (ChannelsView) -- one place computing "was this
|
||||
// a delete" so the two callers can't drift on the cleanup step.
|
||||
bool MyMesh::setChannelLocal(uint8_t idx, const ChannelDetails& ch) {
|
||||
if (!setChannel(idx, ch)) return false;
|
||||
saveChannels();
|
||||
// An all-zero secret is this codebase's "empty slot" sentinel (same check
|
||||
// loadChannels()/saveChannels() use) -- drop anything that referenced it by
|
||||
// index, the same way onContactRemoved() does for contacts.
|
||||
if (_ui && isAllZero(ch.channel.secret, sizeof(ch.channel.secret)))
|
||||
_ui->onChannelRemoved(idx);
|
||||
return true;
|
||||
}
|
||||
|
||||
void MyMesh::handleCmdFrame(size_t len) {
|
||||
if (cmd_frame[0] == CMD_DEVICE_QUERY && len >= 2) { // sent when app establishes connection
|
||||
app_target_ver = cmd_frame[1]; // which version of protocol does app understand
|
||||
@@ -2451,14 +2473,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
ChannelDetails channel;
|
||||
StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32);
|
||||
memcpy(channel.channel.secret, &cmd_frame[2 + 32], 32); // 256-bit key
|
||||
if (setChannel(channel_idx, channel)) {
|
||||
saveChannels();
|
||||
// An all-zero secret is this codebase's "empty slot" sentinel (same
|
||||
// check loadChannels()/saveChannels() use) -- the app just cleared this
|
||||
// channel, so drop anything that referenced it by index, the same way
|
||||
// onContactRemoved() does for contacts.
|
||||
if (_ui && isAllZero(channel.channel.secret, sizeof(channel.channel.secret)))
|
||||
_ui->onChannelRemoved(channel_idx);
|
||||
if (setChannelLocal(channel_idx, channel)) {
|
||||
writeOKFrame();
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
|
||||
@@ -2469,10 +2484,7 @@ void MyMesh::handleCmdFrame(size_t len) {
|
||||
StrHelper::strncpy(channel.name, (char *)&cmd_frame[2], 32);
|
||||
memset(channel.channel.secret, 0, sizeof(channel.channel.secret));
|
||||
memcpy(channel.channel.secret, &cmd_frame[2 + 32], 16); // 128-bit key
|
||||
if (setChannel(channel_idx, channel)) {
|
||||
saveChannels();
|
||||
if (_ui && isAllZero(channel.channel.secret, sizeof(channel.channel.secret)))
|
||||
_ui->onChannelRemoved(channel_idx);
|
||||
if (setChannelLocal(channel_idx, channel)) {
|
||||
writeOKFrame();
|
||||
} else {
|
||||
writeErrFrame(ERR_CODE_NOT_FOUND); // bad channel_idx
|
||||
|
||||
@@ -247,6 +247,22 @@ public:
|
||||
bool getRoomPassword(const uint8_t* pub_key, char* out_password, uint8_t max_len);
|
||||
void forgetRoomPassword(const uint8_t* pub_key);
|
||||
|
||||
// On-device channel add/edit/delete (Messages > Channels). Shares the exact
|
||||
// setChannel + saveChannels + onChannelRemoved-cleanup sequence the
|
||||
// CMD_SET_CHANNEL BLE handler already performs, so both paths stay in sync.
|
||||
bool setChannelLocal(uint8_t idx, const ChannelDetails& ch);
|
||||
|
||||
// On-device "remote admin" (Tools > Admin): send a CLI command to a node
|
||||
// you're logged into with admin permission (see ClientACL::isAdmin()). The
|
||||
// reply is a text frame (TXT_TYPE_CLI_DATA) delivered via onCommandDataRecv();
|
||||
// this just tracks which contact's reply AdminScreen is currently waiting on.
|
||||
bool sendAdminCommand(const ContactInfo& contact, const char* cmd_text, uint32_t& est_timeout) {
|
||||
if (sendCommandData(contact, rtc_clock.getCurrentTime(), 0, cmd_text, est_timeout) == MSG_SEND_FAILED)
|
||||
return false;
|
||||
memcpy(&ui_pending_admin_reply, contact.id.pub_key, 4);
|
||||
return true;
|
||||
}
|
||||
|
||||
void savePrefs() { _store->savePrefs(_prefs, sensors.node_lat, sensors.node_lon); }
|
||||
void saveRTCTime() { _store->saveRTCTime(); }
|
||||
DataStore* getDataStore() const { return _store; }
|
||||
@@ -344,6 +360,7 @@ private:
|
||||
uint32_t pending_login;
|
||||
uint32_t ui_pending_login; // like pending_login, but triggered by on-device UI instead of BLE/USB app
|
||||
char pending_login_pw[16]; // password of the in-flight app/USB login, persisted on success for ADV_TYPE_ROOM (see saveRoomPassword)
|
||||
uint32_t ui_pending_admin_reply; // pub_key prefix of the contact AdminScreen's sendAdminCommand() is awaiting a CLI reply from
|
||||
uint32_t pending_status;
|
||||
uint32_t pending_telemetry, pending_discovery; // pending _TELEMETRY_REQ
|
||||
uint32_t pending_req; // pending _BINARY_REQ
|
||||
|
||||
@@ -331,13 +331,29 @@ struct NodePrefs { // persisted to file
|
||||
// See KeyboardWidget.h for the per-alphabet grids (KB_CYRILLIC_CHARS etc.)
|
||||
// and Lemon font (src/helpers/ui/LemonFont.h) for on-screen rendering —
|
||||
// every alphabet here must be in Lemon's U+0020-04FF range.
|
||||
//
|
||||
// The Latin-diacritic entries (Polish..Nordic) replace what used to be a
|
||||
// single combined "Ext.Latin" curated subset — each is now its own full,
|
||||
// linguistically-correct set of that language's non-ASCII letters instead
|
||||
// of a shared partial one. Not a schema change: keyboard_alt_alphabet is
|
||||
// still just an index into keyboardAlphabetLabel()/KeyboardWidget's tables,
|
||||
// so widening KB_ALPHABET_COUNT needs no persisted-data migration — only
|
||||
// note that an index of 3 now means Polish, not the old combined Ext.Latin.
|
||||
static const uint8_t KB_ALPHABET_LATIN_ONLY = 0;
|
||||
static const uint8_t KB_ALPHABET_CYRILLIC = 1;
|
||||
static const uint8_t KB_ALPHABET_GREEK = 2;
|
||||
static const uint8_t KB_ALPHABET_EXT_LATIN = 3; // Polish/Czech/Slovak/German/French/etc. diacritics
|
||||
static const uint8_t KB_ALPHABET_COUNT = 4;
|
||||
static const uint8_t KB_ALPHABET_POLISH = 3; // ą ć ę ł ń ó ś ź ż
|
||||
static const uint8_t KB_ALPHABET_CZECH = 4; // á č ď é ě í ň ó ř š ť ú ů ý ž
|
||||
static const uint8_t KB_ALPHABET_SLOVAK = 5; // á ä č ď é í ĺ ľ ň ó ô ŕ š ť ú ý ž
|
||||
static const uint8_t KB_ALPHABET_GERMAN = 6; // ä ö ü ß
|
||||
static const uint8_t KB_ALPHABET_FRENCH = 7; // à â ç é è ê ë î ï ô ù û ü ÿ œ
|
||||
static const uint8_t KB_ALPHABET_SPANISH = 8; // á é í ñ ó ú ü
|
||||
static const uint8_t KB_ALPHABET_PORTUGUESE = 9; // á à â ã ç é ê í ó ô õ ú
|
||||
static const uint8_t KB_ALPHABET_NORDIC = 10; // å ä æ ö ø (Danish/Norwegian/Swedish share this set)
|
||||
static const uint8_t KB_ALPHABET_COUNT = 11;
|
||||
static const char* keyboardAlphabetLabel(uint8_t idx) {
|
||||
static const char* L[KB_ALPHABET_COUNT] = { "Latin", "Cyrillic", "Greek", "Ext.Latin" };
|
||||
static const char* L[KB_ALPHABET_COUNT] = { "Latin", "Cyrillic", "Greek", "Polish", "Czech",
|
||||
"Slovak", "German", "French", "Spanish", "Portuguese", "Nordic" };
|
||||
return L[idx < KB_ALPHABET_COUNT ? idx : 0];
|
||||
}
|
||||
|
||||
|
||||
441
examples/companion_radio/ui-new/AdminScreen.h
Normal file
441
examples/companion_radio/ui-new/AdminScreen.h
Normal file
@@ -0,0 +1,441 @@
|
||||
#pragma once
|
||||
// Tools > Admin: send CLI commands to a repeater/room server you have admin
|
||||
// permission on, and see the text reply -- the on-device equivalent of the
|
||||
// companion app's "repeater admin" feature (see docs/cli_commands.md for the
|
||||
// command grammar). The wire protocol already supports this in full
|
||||
// (MyMesh::sendRoomLogin() / sendAdminCommand() / onCommandDataRecv()); this
|
||||
// screen is just the missing UI layer.
|
||||
//
|
||||
// Credentials are session-only by design: the admin password is typed fresh
|
||||
// each time this screen is entered for a given contact (onShow() clears the
|
||||
// one-slot "logged in" memo below) rather than persisted like room chat
|
||||
// passwords are -- admin actions are infrequent, and keeping them out of the
|
||||
// same store as room chat credentials keeps the two concerns separate.
|
||||
//
|
||||
// Once logged in, commands are organised into a category tab carousel (same
|
||||
// "true carousel" idiom BotScreen uses: LEFT/RIGHT exclusively switches tabs,
|
||||
// UP/DOWN moves within the active tab's rows, Enter drives everything else)
|
||||
// instead of a single free-text box, so common settings don't need the CLI
|
||||
// grammar memorised. Each row is one of three shapes, driven by a single
|
||||
// AdminField{label, get_cmd, set_prefix} table per tab -- see fieldAt():
|
||||
// - set_prefix == nullptr -> action: get_cmd is the literal command,
|
||||
// sent immediately (e.g. "reboot").
|
||||
// - get_cmd == nullptr -> set-only: opens a blank keyboard for the
|
||||
// new value (there's no getter for it).
|
||||
// - both set -> get-then-edit-then-set: fetches get_cmd,
|
||||
// opens the keyboard pre-filled with the
|
||||
// raw reply, sends "<set_prefix> <edited>".
|
||||
// The trailing "Custom command..." row (both null) is the escape hatch: it
|
||||
// opens the same free-text entry this screen originally shipped with, for
|
||||
// anything not covered by a field row.
|
||||
|
||||
#include "FullscreenMsgView.h"
|
||||
#include "TabBar.h"
|
||||
#include <helpers/ClientACL.h> // PERM_ACL_ADMIN / PERM_ACL_ROLE_MASK
|
||||
|
||||
class AdminScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
|
||||
enum Phase : uint8_t { PICKER, LOGIN, COMMAND, REPLY };
|
||||
Phase _phase = PICKER;
|
||||
|
||||
// PICKER -- contacts of type ADV_TYPE_REPEATER or ADV_TYPE_ROOM only; a
|
||||
// plain chat contact can never have an admin ACL entry to log into.
|
||||
int _sel = 0, _scroll = 0;
|
||||
int _num = 0;
|
||||
uint16_t _indices[MAX_CONTACTS];
|
||||
ContactInfo _target;
|
||||
|
||||
// LOGIN
|
||||
char _login_pw[16] = "";
|
||||
// True while waiting for the result of a login attempt with no keyboard on
|
||||
// screen -- either a silent retry with a remembered password, or right after
|
||||
// the keyboard-typed password was submitted. Distinct from the keyboard
|
||||
// itself being open (kb().render()/handleInput() only run while this is false).
|
||||
bool _login_waiting = false;
|
||||
|
||||
// One-slot memo: the last contact this screen successfully admin-logged
|
||||
// into this visit, so re-entering COMMAND for the same target right after
|
||||
// doesn't require retyping the password. Cleared in onShow() -- see the
|
||||
// session-only design note above.
|
||||
uint8_t _admin_ok_prefix[4] = { 0, 0, 0, 0 };
|
||||
bool _admin_ok = false;
|
||||
|
||||
// COMMAND -- category tabs + rows (see file header for the row shapes).
|
||||
enum AdminTab : uint8_t { ATAB_SYSTEM, ATAB_RADIO, ATAB_ROUTING, ATAB_ACTIONS, ATAB_COUNT };
|
||||
static const char* TAB_LABELS[ATAB_COUNT];
|
||||
|
||||
struct AdminField { const char* label; const char* get_cmd; const char* set_prefix; };
|
||||
static const AdminField SYSTEM_FIELDS[];
|
||||
static const AdminField RADIO_FIELDS[];
|
||||
static const AdminField ROUTING_FIELDS[];
|
||||
static const AdminField ACTION_FIELDS[];
|
||||
static const int ROWS_PER_TAB[ATAB_COUNT];
|
||||
|
||||
static const AdminField& fieldAt(int tab, int row) {
|
||||
switch (tab) {
|
||||
case ATAB_SYSTEM: return SYSTEM_FIELDS[row];
|
||||
case ATAB_RADIO: return RADIO_FIELDS[row];
|
||||
case ATAB_ROUTING: return ROUTING_FIELDS[row];
|
||||
default: return ACTION_FIELDS[row];
|
||||
}
|
||||
}
|
||||
|
||||
uint8_t _tab = ATAB_SYSTEM;
|
||||
int _row_sel = 0, _row_scroll = 0;
|
||||
|
||||
bool _kb_active = false;
|
||||
char _cmd_text[161] = "";
|
||||
bool _waiting = false;
|
||||
uint32_t _cmd_deadline_ms = 0;
|
||||
bool _fetch_for_edit = false; // true while waiting on a "get" whose reply opens an editor
|
||||
const char* _pending_set_prefix = nullptr; // non-null => the edited keyboard text gets this prefix on submit
|
||||
|
||||
// REPLY
|
||||
FullscreenMsgView _reply_view;
|
||||
char _reply_text[200] = "";
|
||||
|
||||
KeyboardWidget& kb() { return _task->keyboard(); }
|
||||
|
||||
void buildList() {
|
||||
ContactInfo c;
|
||||
int total = the_mesh.getNumContacts();
|
||||
_num = 0;
|
||||
for (int i = 0; i < total; i++) {
|
||||
if (!the_mesh.getContactByIdx(i, c)) continue;
|
||||
if (c.type != ADV_TYPE_REPEATER && c.type != ADV_TYPE_ROOM) continue;
|
||||
_indices[_num++] = (uint16_t)i;
|
||||
}
|
||||
}
|
||||
|
||||
bool isAdminOk(const uint8_t* pub_key) const {
|
||||
return _admin_ok && memcmp(_admin_ok_prefix, pub_key, 4) == 0;
|
||||
}
|
||||
|
||||
void startLogin() {
|
||||
_login_pw[0] = '\0';
|
||||
_login_waiting = false;
|
||||
kb().begin("", 15); // admin password: same max length as room/repeater login
|
||||
kb().clearPlaceholders(); // {loc}/{time} are for messages, not a password
|
||||
_phase = LOGIN;
|
||||
}
|
||||
|
||||
// Silent retry with a remembered password (see PICKER's Enter handler) --
|
||||
// same idea as MessagesScreen's room-login flow: no keyboard shown, just a
|
||||
// "Logging in..." wait for the async result.
|
||||
void startLoginWithSaved(const char* password) {
|
||||
strncpy(_login_pw, password, sizeof(_login_pw) - 1);
|
||||
_login_pw[sizeof(_login_pw) - 1] = '\0';
|
||||
bool sent = the_mesh.sendRoomLogin(_target, _login_pw);
|
||||
_task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500);
|
||||
if (sent) { _login_waiting = true; _phase = LOGIN; }
|
||||
}
|
||||
|
||||
void sendCommand() {
|
||||
uint32_t est_timeout = 0;
|
||||
if (!the_mesh.sendAdminCommand(_target, _cmd_text, est_timeout)) {
|
||||
_task->showAlert("Send failed", 1200);
|
||||
_fetch_for_edit = false;
|
||||
_pending_set_prefix = nullptr;
|
||||
return;
|
||||
}
|
||||
_waiting = true;
|
||||
// Generous margin over the base estimate, same reasoning as the DM ACK
|
||||
// deadline in MessagesScreen::sendText() -- a slow multi-hop reply isn't
|
||||
// prematurely shown as a timeout.
|
||||
_cmd_deadline_ms = millis() + est_timeout + 4000;
|
||||
_task->showAlert(_fetch_for_edit ? "Fetching..." : "Sent, waiting...", 800);
|
||||
}
|
||||
|
||||
// Open the free-text command keyboard, pre-filled with `initial`. Shared by
|
||||
// the Custom-command row, set-only fields, and the get-then-edit reply path.
|
||||
void openValueKb(const char* initial) {
|
||||
kb().begin(initial, (int)sizeof(_cmd_text) - 1);
|
||||
kb().clearPlaceholders(); // a CLI command/value is literal, not a message
|
||||
_kb_active = true;
|
||||
}
|
||||
|
||||
// Dispatch Enter on a COMMAND-phase row per the three field shapes (see
|
||||
// file header). idx == ROWS_PER_TAB[_tab]-1 on the Actions tab is the
|
||||
// "Custom command..." sentinel (both null), handled first.
|
||||
void activateField(const AdminField& f) {
|
||||
_fetch_for_edit = false;
|
||||
_pending_set_prefix = nullptr;
|
||||
if (f.get_cmd == nullptr && f.set_prefix == nullptr) { // Custom command...
|
||||
openValueKb(_cmd_text);
|
||||
} else if (f.set_prefix == nullptr) { // Action
|
||||
strncpy(_cmd_text, f.get_cmd, sizeof(_cmd_text) - 1);
|
||||
_cmd_text[sizeof(_cmd_text) - 1] = '\0';
|
||||
sendCommand();
|
||||
} else if (f.get_cmd == nullptr) { // Set-only
|
||||
_pending_set_prefix = f.set_prefix;
|
||||
openValueKb("");
|
||||
} else { // Get-then-edit-then-set
|
||||
_pending_set_prefix = f.set_prefix;
|
||||
_fetch_for_edit = true;
|
||||
strncpy(_cmd_text, f.get_cmd, sizeof(_cmd_text) - 1);
|
||||
_cmd_text[sizeof(_cmd_text) - 1] = '\0';
|
||||
sendCommand();
|
||||
}
|
||||
}
|
||||
|
||||
// A fetch-for-edit didn't get a reply (timeout or the user cancelled the
|
||||
// wait) -- fall back to a blank editor rather than dead-ending, so the
|
||||
// field can still be set blind.
|
||||
void fallBackToBlankEdit() {
|
||||
_fetch_for_edit = false;
|
||||
openValueKb("");
|
||||
}
|
||||
|
||||
public:
|
||||
explicit AdminScreen(UITask* task) : _task(task) {}
|
||||
|
||||
// Central per-visit reset (see UIScreen::onShow) -- always starts back at
|
||||
// the target picker and forgets the admin-login memo, so credentials really
|
||||
// are re-typed each time the tool is (re-)entered.
|
||||
void onShow() override {
|
||||
_phase = PICKER;
|
||||
_sel = _scroll = 0;
|
||||
buildList();
|
||||
_tab = ATAB_SYSTEM;
|
||||
_row_sel = _row_scroll = 0;
|
||||
_kb_active = false;
|
||||
_waiting = false;
|
||||
_fetch_for_edit = false;
|
||||
_pending_set_prefix = nullptr;
|
||||
_login_waiting = false;
|
||||
_admin_ok = false;
|
||||
}
|
||||
|
||||
// Async result of the on-device login, routed here from UITask::onRoomLoginResult()
|
||||
// only while this screen is current (see that routing for why).
|
||||
void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) {
|
||||
if (_phase != LOGIN) return; // stale/unrelated result
|
||||
_login_waiting = false;
|
||||
if (success && (permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN) {
|
||||
memcpy(_admin_ok_prefix, pub_key, 4);
|
||||
_admin_ok = true;
|
||||
the_mesh.saveRoomPassword(pub_key, _login_pw); // remember it -- same logic as room login
|
||||
_tab = ATAB_SYSTEM;
|
||||
_row_sel = _row_scroll = 0;
|
||||
_phase = COMMAND;
|
||||
_task->showAlert("Logged in (admin)", 1000);
|
||||
} else if (success) {
|
||||
// Correct password, just insufficient permission -- leave any saved
|
||||
// password alone, retyping the same one won't change the outcome.
|
||||
_phase = PICKER;
|
||||
_task->showAlert("Not admin on this node", 1600);
|
||||
} else {
|
||||
// Wrong/stale password -- forget it so the next attempt prompts fresh,
|
||||
// same self-healing behaviour as a room login.
|
||||
the_mesh.forgetRoomPassword(pub_key);
|
||||
_phase = PICKER;
|
||||
_task->showAlert("Login failed", 1400);
|
||||
}
|
||||
}
|
||||
|
||||
// Async CLI reply, routed here from UITask::onAdminReply().
|
||||
void onAdminReply(const uint8_t* pub_key, const char* text) {
|
||||
if (!_waiting || memcmp(_target.id.pub_key, pub_key, 4) != 0) return;
|
||||
_waiting = false;
|
||||
if (_fetch_for_edit) {
|
||||
_fetch_for_edit = false;
|
||||
char trimmed[161];
|
||||
strncpy(trimmed, text, sizeof(trimmed) - 1);
|
||||
trimmed[sizeof(trimmed) - 1] = '\0';
|
||||
size_t n = strlen(trimmed);
|
||||
while (n > 0 && (trimmed[n-1] == '\n' || trimmed[n-1] == '\r' || trimmed[n-1] == ' ')) trimmed[--n] = '\0';
|
||||
openValueKb(trimmed); // _pending_set_prefix is already set from activateField()
|
||||
return;
|
||||
}
|
||||
strncpy(_reply_text, text, sizeof(_reply_text) - 1);
|
||||
_reply_text[sizeof(_reply_text) - 1] = '\0';
|
||||
_reply_view.begin();
|
||||
_phase = REPLY;
|
||||
}
|
||||
|
||||
void poll() override {
|
||||
if (_phase == COMMAND && _waiting && (int32_t)(millis() - _cmd_deadline_ms) >= 0) {
|
||||
_waiting = false;
|
||||
if (_fetch_for_edit) { fallBackToBlankEdit(); _task->showAlert("Fetch failed - enter value", 1400); }
|
||||
else { _task->showAlert("No response (timeout)", 1600); }
|
||||
}
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) override {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
if (_phase == PICKER) {
|
||||
display.drawCenteredHeader("ADMIN: SELECT NODE");
|
||||
if (_num == 0) {
|
||||
display.drawTextCentered(display.width() / 2, display.height() / 2, "No repeaters/rooms");
|
||||
return 5000;
|
||||
}
|
||||
drawList(display, _num, _sel, _scroll, [&](int idx, int y, bool sel, int reserve) {
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
ContactInfo c;
|
||||
if (the_mesh.getContactByIdx(_indices[idx], c)) {
|
||||
char filtered[sizeof(c.name)];
|
||||
display.translateUTF8ToBlocks(filtered, c.name, sizeof(filtered));
|
||||
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, filtered);
|
||||
}
|
||||
});
|
||||
return 2000;
|
||||
}
|
||||
|
||||
if (_phase == LOGIN) {
|
||||
if (_login_waiting) {
|
||||
display.drawCenteredHeader("ADMIN LOGIN");
|
||||
display.setCursor(2, display.listStart());
|
||||
display.print("Logging in...");
|
||||
return 500;
|
||||
}
|
||||
return kb().render(display);
|
||||
}
|
||||
|
||||
if (_phase == COMMAND) {
|
||||
if (_kb_active) return kb().render(display);
|
||||
if (_waiting) {
|
||||
char title[24];
|
||||
snprintf(title, sizeof(title), "%.23s", _target.name);
|
||||
display.drawCenteredHeader(title);
|
||||
display.setCursor(2, display.listStart());
|
||||
display.print(_fetch_for_edit ? "Fetching..." : "Waiting for reply...");
|
||||
return 500;
|
||||
}
|
||||
tabbar::draw(display, TAB_LABELS, ATAB_COUNT, _tab);
|
||||
int n = ROWS_PER_TAB[_tab];
|
||||
drawList(display, n, _row_sel, _row_scroll, [&](int i, int y, bool sel, int reserve) {
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
display.drawTextEllipsized(2, y, display.width() - 4 - reserve, fieldAt(_tab, i).label);
|
||||
});
|
||||
return 2000;
|
||||
}
|
||||
|
||||
// REPLY
|
||||
return _reply_view.render(display, _target.name, _reply_text, false, false);
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
if (_phase == PICKER) {
|
||||
if (c == KEY_CANCEL) { _task->gotoToolsScreen(); return true; }
|
||||
if (c == KEY_UP && _num > 0) { _sel = (_sel > 0) ? _sel - 1 : _num - 1; return true; }
|
||||
if (c == KEY_DOWN && _num > 0) { _sel = (_sel < _num - 1) ? _sel + 1 : 0; return true; }
|
||||
if (c == KEY_ENTER && _num > 0) {
|
||||
if (the_mesh.getContactByIdx(_indices[_sel], _target)) {
|
||||
if (isAdminOk(_target.id.pub_key)) {
|
||||
_tab = ATAB_SYSTEM; _row_sel = _row_scroll = 0;
|
||||
_phase = COMMAND;
|
||||
} else {
|
||||
// Known password from an earlier successful admin login (this boot
|
||||
// or a previous one) -- retry silently instead of re-prompting,
|
||||
// same as MessagesScreen's room-login flow.
|
||||
char saved_pw[sizeof(_login_pw)];
|
||||
if (the_mesh.getRoomPassword(_target.id.pub_key, saved_pw, sizeof(saved_pw)))
|
||||
startLoginWithSaved(saved_pw);
|
||||
else
|
||||
startLogin();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_phase == LOGIN) {
|
||||
if (_login_waiting) {
|
||||
if (c == KEY_CANCEL) { _login_waiting = false; _phase = PICKER; }
|
||||
return true;
|
||||
}
|
||||
auto r = kb().handleInput(c);
|
||||
if (r == KeyboardWidget::CANCELLED) {
|
||||
_phase = PICKER;
|
||||
} else if (r == KeyboardWidget::DONE) {
|
||||
strncpy(_login_pw, kb().buf, sizeof(_login_pw) - 1);
|
||||
_login_pw[sizeof(_login_pw) - 1] = '\0';
|
||||
bool sent = the_mesh.sendRoomLogin(_target, _login_pw);
|
||||
_task->showAlert(sent ? "Logging in..." : "Login failed", sent ? 1000 : 1500);
|
||||
if (sent) _login_waiting = true; else _phase = PICKER;
|
||||
// else: stay in LOGIN until onRoomLoginResult() fires above.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_phase == COMMAND) {
|
||||
if (_kb_active) {
|
||||
auto r = kb().handleInput(c);
|
||||
if (r == KeyboardWidget::DONE) {
|
||||
_kb_active = false;
|
||||
char edited[sizeof(_cmd_text)];
|
||||
strncpy(edited, kb().buf, sizeof(edited) - 1);
|
||||
edited[sizeof(edited) - 1] = '\0';
|
||||
if (_pending_set_prefix) {
|
||||
snprintf(_cmd_text, sizeof(_cmd_text), "%s %s", _pending_set_prefix, edited);
|
||||
_pending_set_prefix = nullptr;
|
||||
} else {
|
||||
strncpy(_cmd_text, edited, sizeof(_cmd_text) - 1);
|
||||
_cmd_text[sizeof(_cmd_text) - 1] = '\0';
|
||||
}
|
||||
if (_cmd_text[0]) sendCommand();
|
||||
} else if (r == KeyboardWidget::CANCELLED) {
|
||||
_kb_active = false;
|
||||
_pending_set_prefix = nullptr;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (_waiting) {
|
||||
if (c == KEY_CANCEL) {
|
||||
_waiting = false;
|
||||
if (_fetch_for_edit) fallBackToBlankEdit();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CANCEL) { _phase = PICKER; return true; }
|
||||
if (keyIsPrev(c)) { _tab = (_tab + ATAB_COUNT - 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; }
|
||||
if (keyIsNext(c)) { _tab = (_tab + 1) % ATAB_COUNT; _row_sel = _row_scroll = 0; return true; }
|
||||
int n = ROWS_PER_TAB[_tab];
|
||||
if (c == KEY_UP) { _row_sel = (_row_sel > 0) ? _row_sel - 1 : n - 1; return true; }
|
||||
if (c == KEY_DOWN) { _row_sel = (_row_sel < n - 1) ? _row_sel + 1 : 0; return true; }
|
||||
if (c == KEY_ENTER) { activateField(fieldAt(_tab, _row_sel)); return true; }
|
||||
return true;
|
||||
}
|
||||
|
||||
// REPLY
|
||||
auto res = _reply_view.handleInput(c);
|
||||
if (res != FullscreenMsgView::NONE) _phase = COMMAND; // CLOSE / PREV / NEXT / REPLY all just return here
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const char* AdminScreen::TAB_LABELS[AdminScreen::ATAB_COUNT] = { "System", "Radio", "Routing", "Actions" };
|
||||
|
||||
const AdminScreen::AdminField AdminScreen::SYSTEM_FIELDS[] = {
|
||||
{ "Name", "get name", "set name" },
|
||||
{ "Owner info", "get owner.info", "set owner.info" },
|
||||
{ "Admin password", nullptr, "password" },
|
||||
};
|
||||
const AdminScreen::AdminField AdminScreen::RADIO_FIELDS[] = {
|
||||
{ "Radio (f,bw,sf,cr)", "get radio", "set radio" },
|
||||
{ "TX power", "get tx", "set tx" },
|
||||
};
|
||||
const AdminScreen::AdminField AdminScreen::ROUTING_FIELDS[] = {
|
||||
{ "Repeat", "get repeat", "set repeat" },
|
||||
{ "Advert interval (min)", "get advert.interval", "set advert.interval" },
|
||||
{ "Flood advert interval (h)", "get flood.advert.interval", "set flood.advert.interval" },
|
||||
{ "Max hops", "get flood.max", "set flood.max" },
|
||||
};
|
||||
const AdminScreen::AdminField AdminScreen::ACTION_FIELDS[] = {
|
||||
{ "Reboot", "reboot", nullptr },
|
||||
{ "Send advert", "advert", nullptr },
|
||||
{ "Send zero-hop advert", "advert.zerohop", nullptr },
|
||||
{ "Sync clock", "clock sync", nullptr },
|
||||
{ "Custom command...", nullptr, nullptr },
|
||||
};
|
||||
|
||||
const int AdminScreen::ROWS_PER_TAB[AdminScreen::ATAB_COUNT] = {
|
||||
sizeof(AdminScreen::SYSTEM_FIELDS) / sizeof(AdminScreen::AdminField),
|
||||
sizeof(AdminScreen::RADIO_FIELDS) / sizeof(AdminScreen::AdminField),
|
||||
sizeof(AdminScreen::ROUTING_FIELDS) / sizeof(AdminScreen::AdminField),
|
||||
sizeof(AdminScreen::ACTION_FIELDS) / sizeof(AdminScreen::AdminField),
|
||||
};
|
||||
@@ -2,16 +2,17 @@
|
||||
// Custom screen — not part of upstream UITask.cpp
|
||||
// Included by UITask.cpp after KeyboardWidget.h is defined.
|
||||
|
||||
#include "TabBar.h"
|
||||
|
||||
class BotScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
NodePrefs* _prefs;
|
||||
|
||||
// Categories are a circular tab bar in the header (mirrors NearbyScreen's
|
||||
// filter tabs — duplicated locally rather than shared, since this is the
|
||||
// only other screen using the pattern so far). LEFT/RIGHT switches tabs;
|
||||
// UP/DOWN moves within the active tab's rows. Because LEFT/RIGHT is fully
|
||||
// owned by tab-switching, every row's value is edited via Enter only — no
|
||||
// more inline LEFT/RIGHT cycling.
|
||||
// Categories are a circular tab bar in the header (shared geometry with
|
||||
// NearbyScreen's filter tabs and AdminScreen's category tabs — see
|
||||
// TabBar.h). LEFT/RIGHT switches tabs; UP/DOWN moves within the active
|
||||
// tab's rows. Because LEFT/RIGHT is fully owned by tab-switching, every
|
||||
// row's value is edited via Enter only — no more inline LEFT/RIGHT cycling.
|
||||
enum Tab : uint8_t { TAB_CHANNEL, TAB_ROOM, TAB_DIRECT, TAB_OTHER, TAB_COUNT };
|
||||
static const char* TAB_LABELS[TAB_COUNT];
|
||||
|
||||
@@ -96,56 +97,10 @@ class BotScreen : public UIScreen {
|
||||
if (the_mesh.getContactByIdx(i, ci) && ci.type == ADV_TYPE_ROOM) _num_rooms++;
|
||||
}
|
||||
|
||||
// One tab: short label; the active one is an inverted pill (same look as a
|
||||
// selected row) so it reads as "you are here" in the tab strip.
|
||||
void drawTab(DisplayDriver& display, int x, int w, const char* label, bool active) {
|
||||
int lh = display.getLineHeight();
|
||||
if (active) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(x, 0, w, lh + 1);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.drawTextCentered(x + w / 2, 0, label);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
// Header as a circular tab bar: the active tab sits centred as a filled
|
||||
// pill, neighbours fan out either side and wrap around. Only whole tabs are
|
||||
// drawn — one that would spill past a screen edge is skipped rather than
|
||||
// clipped. `right_reserve` keeps the reply counter (drawn after this call)
|
||||
// clear of the rightmost tab.
|
||||
// Header as a circular tab bar (shared geometry — see TabBar.h). `right_reserve`
|
||||
// keeps the reply counter (drawn after this call) clear of the rightmost tab.
|
||||
void drawTabBar(DisplayDriver& display, int right_reserve) {
|
||||
const int pad = 3, gap = 2;
|
||||
int w[TAB_COUNT];
|
||||
for (int i = 0; i < TAB_COUNT; i++)
|
||||
w[i] = display.getTextWidth(TAB_LABELS[i]) + pad * 2;
|
||||
|
||||
int ax = display.width() / 2 - w[_tab] / 2;
|
||||
drawTab(display, ax, w[_tab], TAB_LABELS[_tab], true);
|
||||
|
||||
int lx = ax - gap;
|
||||
int rx = ax + w[_tab] + gap;
|
||||
int rx_limit = display.width() - right_reserve;
|
||||
bool lfit = true, rfit = true;
|
||||
for (int k = 1; k <= TAB_COUNT / 2 && (lfit || rfit); k++) {
|
||||
int li = (_tab - k + TAB_COUNT) % TAB_COUNT;
|
||||
int ri = (_tab + k) % TAB_COUNT;
|
||||
if (lfit) {
|
||||
int x = lx - w[li];
|
||||
if (x >= 0) { drawTab(display, x, w[li], TAB_LABELS[li], false); lx = x - gap; }
|
||||
else lfit = false;
|
||||
}
|
||||
// Skip the right side when it lands on the same tab as the left (the
|
||||
// single opposite tab on an even count) so it isn't drawn twice.
|
||||
if (rfit && ri != li) {
|
||||
if (rx + w[ri] <= rx_limit) { drawTab(display, rx, w[ri], TAB_LABELS[ri], false); rx += w[ri] + gap; }
|
||||
else rfit = false;
|
||||
}
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH());
|
||||
tabbar::draw(display, TAB_LABELS, TAB_COUNT, _tab, right_reserve);
|
||||
}
|
||||
|
||||
public:
|
||||
|
||||
159
examples/companion_radio/ui-new/ChannelsView.h
Normal file
159
examples/companion_radio/ui-new/ChannelsView.h
Normal file
@@ -0,0 +1,159 @@
|
||||
#pragma once
|
||||
// On-device channel Add/Edit form (Messages > Channels > "+ Add channel" / Edit).
|
||||
// Owned by MessagesScreen, which delegates render()/handleInput() to it while
|
||||
// active() — the same relationship WaypointsView has with TrailScreen.
|
||||
//
|
||||
// The Secret field toggles between two entry modes with LEFT/RIGHT:
|
||||
// - Passphrase (default): any text, SHA-256'd down to 16 bytes — the same
|
||||
// primitive BaseChatMesh::addChannel()/setChannel() already use to derive
|
||||
// a channel's routing hash, just applied here to a typed phrase instead of
|
||||
// a raw key. Easiest to agree on verbally, like a room password.
|
||||
// - Hex key: the exact 32-hex-char (128-bit) secret, e.g. from a channel QR
|
||||
// (see docs/qr_codes.md) — for joining a channel whose precise secret you
|
||||
// were given rather than agreeing on a new passphrase.
|
||||
// Either way only a 16-byte (128-bit) secret is produced, matching the
|
||||
// existing CMD_GET_CHANNEL comment ("NOTE: only 128-bit supported").
|
||||
|
||||
#include <Utils.h>
|
||||
|
||||
class ChannelsView {
|
||||
UITask* _task;
|
||||
|
||||
enum Mode : uint8_t { OFF, ADD, EDIT };
|
||||
uint8_t _mode = OFF;
|
||||
int _idx = -1; // channel slot being added/edited
|
||||
|
||||
char _name[32] = "";
|
||||
bool _hex_mode = false; // false = passphrase, true = 32-hex-char raw key
|
||||
char _secret_text[33] = ""; // passphrase or hex string typed so far
|
||||
|
||||
int _sel = 0; // 0=Name 1=Secret 2=[Save]
|
||||
bool _kb_active = false;
|
||||
int _kb_field = -1; // 0=Name, 1=Secret
|
||||
|
||||
KeyboardWidget& kb() { return _task->keyboard(); }
|
||||
|
||||
void openKb(const char* initial, int max) {
|
||||
kb().begin(initial, max);
|
||||
kb().clearPlaceholders(); // literal field — {loc}/{time} make no sense here
|
||||
_kb_active = true;
|
||||
}
|
||||
|
||||
// Turn the typed Secret field into a 16-byte channel secret. Returns false
|
||||
// (and leaves out[] untouched) if the field can't be parsed as configured.
|
||||
bool deriveSecret(uint8_t out[32]) const {
|
||||
if (_hex_mode) {
|
||||
if (strlen(_secret_text) != 32) return false;
|
||||
uint8_t tmp[16];
|
||||
for (int i = 0; i < 16; i++) {
|
||||
char byte_str[3] = { _secret_text[i * 2], _secret_text[i * 2 + 1], 0 };
|
||||
char* end = nullptr;
|
||||
long v = strtol(byte_str, &end, 16);
|
||||
if (*end != 0) return false;
|
||||
tmp[i] = (uint8_t)v;
|
||||
}
|
||||
memset(out, 0, 32);
|
||||
memcpy(out, tmp, 16);
|
||||
return true;
|
||||
}
|
||||
if (_secret_text[0] == '\0') return false; // blank passphrase not allowed
|
||||
uint8_t digest[32];
|
||||
mesh::Utils::sha256(digest, sizeof(digest), (const uint8_t*)_secret_text, (int)strlen(_secret_text));
|
||||
memset(out, 0, 32);
|
||||
memcpy(out, digest, 16);
|
||||
return true;
|
||||
}
|
||||
|
||||
void commit() {
|
||||
if (_name[0] == '\0') { _task->showAlert("Name required", 1200); return; }
|
||||
uint8_t secret[32];
|
||||
if (!deriveSecret(secret)) {
|
||||
_task->showAlert(_hex_mode ? "Need 32 hex chars" : "Secret required", 1400);
|
||||
return;
|
||||
}
|
||||
ChannelDetails ch;
|
||||
memset(&ch, 0, sizeof(ch));
|
||||
strncpy(ch.name, _name, sizeof(ch.name) - 1);
|
||||
memcpy(ch.channel.secret, secret, sizeof(ch.channel.secret));
|
||||
if (the_mesh.setChannelLocal((uint8_t)_idx, ch)) {
|
||||
_task->showAlert(_mode == ADD ? "Channel added" : "Channel updated", 1000);
|
||||
_mode = OFF;
|
||||
} else {
|
||||
_task->showAlert("Save failed", 1200);
|
||||
}
|
||||
}
|
||||
|
||||
public:
|
||||
explicit ChannelsView(UITask* task) : _task(task) {}
|
||||
|
||||
bool active() const { return _mode != OFF || _kb_active; }
|
||||
void reset() { _mode = OFF; _kb_active = false; _kb_field = -1; }
|
||||
|
||||
// idx = the slot to write on Save (first free slot for Add, existing slot
|
||||
// for Edit). Edit pre-fills Name; the Secret can't be redisplayed (only the
|
||||
// derived key is stored), so editing it means typing a new passphrase/hex —
|
||||
// same as re-logging into a room with a new password.
|
||||
void openAdd(int idx) {
|
||||
_mode = ADD; _idx = idx;
|
||||
_name[0] = '\0'; _secret_text[0] = '\0'; _hex_mode = false; _sel = 0;
|
||||
}
|
||||
void openEdit(int idx, const char* existing_name) {
|
||||
_mode = EDIT; _idx = idx;
|
||||
strncpy(_name, existing_name, sizeof(_name) - 1); _name[sizeof(_name) - 1] = '\0';
|
||||
_secret_text[0] = '\0'; _hex_mode = false; _sel = 0;
|
||||
}
|
||||
|
||||
int render(DisplayDriver& display) {
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
if (_kb_active) return kb().render(display);
|
||||
|
||||
display.drawCenteredHeader(_mode == ADD ? "ADD CHANNEL" : "EDIT CHANNEL");
|
||||
const int top = display.listStart();
|
||||
const int step = display.lineStep();
|
||||
for (int i = 0; i < 3; i++) {
|
||||
int y = top + i * step;
|
||||
bool sel = (i == _sel);
|
||||
display.drawSelectionRow(0, y - 1, display.width(), step - 1, sel);
|
||||
char row[40];
|
||||
if (i == 0) snprintf(row, sizeof(row), "Name: %s", _name[0] ? _name : "(none)");
|
||||
else if (i == 1) snprintf(row, sizeof(row), "Secret (%s): %s",
|
||||
_hex_mode ? "hex" : "phrase", _secret_text[0] ? _secret_text : "(none)");
|
||||
else snprintf(row, sizeof(row), "[Save]");
|
||||
// Ellipsize rather than print() directly -- a long name/secret must not
|
||||
// wrap onto the next row's line (print() wraps by default).
|
||||
display.drawTextEllipsized(2, y, display.width() - 4, row);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
return 1000;
|
||||
}
|
||||
|
||||
bool handleInput(char c) {
|
||||
if (_kb_active) {
|
||||
auto r = kb().handleInput(c);
|
||||
if (r == KeyboardWidget::DONE) {
|
||||
if (_kb_field == 0) { strncpy(_name, kb().buf, sizeof(_name) - 1); _name[sizeof(_name) - 1] = '\0'; }
|
||||
else { strncpy(_secret_text, kb().buf, sizeof(_secret_text) - 1); _secret_text[sizeof(_secret_text) - 1] = '\0'; }
|
||||
_kb_active = false; _kb_field = -1;
|
||||
} else if (r == KeyboardWidget::CANCELLED) {
|
||||
_kb_active = false; _kb_field = -1;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CANCEL) { _mode = OFF; return true; }
|
||||
if (c == KEY_UP) { _sel = (_sel > 0) ? _sel - 1 : 2; return true; }
|
||||
if (c == KEY_DOWN) { _sel = (_sel < 2) ? _sel + 1 : 0; return true; }
|
||||
if ((keyIsPrev(c) || keyIsNext(c)) && _sel == 1) {
|
||||
_hex_mode = !_hex_mode;
|
||||
_secret_text[0] = '\0'; // switching mode invalidates whatever was typed
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER) {
|
||||
if (_sel == 0) { _kb_field = 0; openKb(_name, sizeof(_name) - 1); }
|
||||
else if (_sel == 1) { _kb_field = 1; openKb(_secret_text, _hex_mode ? 32 : (int)sizeof(_secret_text) - 1); }
|
||||
else commit();
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
@@ -18,7 +18,7 @@ static char s_wrap_lines[12][FS_CHARS_MAX];
|
||||
// Parse a leading "@[nick] " reply prefix. Returns the message body that
|
||||
// follows it (and any leading whitespace); when nick/nick_n are supplied,
|
||||
// fills nick with the addressee, or "" when there's no prefix. One parser for
|
||||
// both the history list (body only — see QuickMsgScreen::skipReplyPrefix) and
|
||||
// both the history list (body only — see MessagesScreen::skipReplyPrefix) and
|
||||
// the fullscreen view (which also shows the "To:" nick).
|
||||
static inline const char* msgReplyBody(const char* text, char* nick = nullptr, int nick_n = 0) {
|
||||
if (nick && nick_n > 0) nick[0] = '\0';
|
||||
|
||||
@@ -73,7 +73,8 @@ static const char* const KB_T9_GROUPS_CYRILLIC[9] = {
|
||||
// Greek ABC grid: the 24-letter modern alphabet plus final sigma (ς, used
|
||||
// only at the end of a word — σ is the regular form) = 25 letters, fitting
|
||||
// rows 0-2 with 5 basic punctuation marks to spare; row 3 keeps the digit row
|
||||
// (unlike Cyrillic/Ext.Latin, Greek has room left over).
|
||||
// (unlike Cyrillic or most of the Latin-diacritic alphabets below, Greek has
|
||||
// room left over in its own letter rows).
|
||||
// NOTE: monotonic Modern Greek normally marks stress with a tonos accent
|
||||
// (ά έ ή ί ό ύ ώ) — omitted here to keep this a single, simple page. Fine for
|
||||
// informal/transliteration-style typing; flag if proper accented Greek
|
||||
@@ -91,21 +92,106 @@ static const char* const KB_T9_GROUPS_GREEK[9] = {
|
||||
".,!?'-", "αβγ", "δεζ", "ηθι", "κλμ", "νξο", "πρσς", "τυφ", "χψω"
|
||||
};
|
||||
|
||||
// Extended Latin ABC grid: diacritics for Polish, Czech/Slovak, German,
|
||||
// French, Spanish/Portuguese and Nordic — the common non-ASCII Latin letters,
|
||||
// not full Unicode coverage. 34 letters + 6 basic punctuation marks fill all
|
||||
// 40 cells; no digit row on this page (digits are reachable via the Symbols
|
||||
// page, same as Cyrillic).
|
||||
static const char* const KB_EXTLATIN_CHARS[4][10] = {
|
||||
{ "ą","ć","ę","ł","ń","ó","ś","ź","ż","ö" }, // Polish (9) + German ö
|
||||
{ "ü","ä","ß","č","ď","ě","ň","ř","š","ť" }, // German (3) + Czech/Slovak (7)
|
||||
{ "ů","ž","é","è","ê","ë","à","ç","ù","ñ" }, // Czech/Slovak (2) + French (6) + Spanish ñ
|
||||
{ "å","ø","æ","õ",".",",","!","?","-","'" }, // Nordic (3) + Portuguese õ + punctuation
|
||||
// Per-language Latin-diacritic ABC grids. Each is that language's own full
|
||||
// set of non-ASCII letters (not a shared curated subset — see NodePrefs.h's
|
||||
// KB_ALPHABET_POLISH..KB_ALPHABET_NORDIC doc comment for why these replaced a
|
||||
// single combined "Ext.Latin" page). Every grid is filled to all 40 cells the
|
||||
// same way: letters first, then punctuation to round out the last letter
|
||||
// row, then a full digit row, then any further rows filled with more
|
||||
// punctuation/symbols — consistent shape regardless of how few letters a
|
||||
// given language actually needs (German's 4, at the small end, still gets a
|
||||
// full page rather than mostly-blank cells).
|
||||
static const char* const KB_POLISH_CHARS[4][10] = {
|
||||
{ "ą", "ć", "ę", "ł", "ń", "ó", "ś", "ź", "ż", "." },
|
||||
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" },
|
||||
{ ",", "!", "?", "'", "-", "\"", ":", ";", "(", ")" },
|
||||
{ "@", "#", "&", "*", "_", "+", "=", "/", "\\", "~" },
|
||||
};
|
||||
// Extended Latin T9 groups: cell 0 (digit '1') is punctuation; cells 1-8
|
||||
// (digits '2'-'9') cluster by language for memorability.
|
||||
static const char* const KB_T9_GROUPS_EXTLATIN[9] = {
|
||||
".,!?'-", "ąćęł", "ńóśźż", "äöüß", "čďěň", "řšťůž", "éèêë", "àçùñ", "åøæõ"
|
||||
static const char* const KB_T9_GROUPS_POLISH[9] = {
|
||||
".,!?'-", "ąć", "ęł", "ńó", "śź", "ż", ";()", "@#&*", "_+=/"
|
||||
};
|
||||
|
||||
static const char* const KB_CZECH_CHARS[4][10] = {
|
||||
{ "á", "č", "ď", "é", "ě", "í", "ň", "ó", "ř", "š" },
|
||||
{ "ť", "ú", "ů", "ý", "ž", ".", ",", "!", "?", "'" },
|
||||
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" },
|
||||
{ "-", "\"", ":", ";", "(", ")", "@", "#", "&", "*" },
|
||||
};
|
||||
static const char* const KB_T9_GROUPS_CZECH[9] = {
|
||||
".,!?'-", "áč", "ďé", "ěí", "ňó", "řš", "ťú", "ůý", "ž"
|
||||
};
|
||||
|
||||
// Slovak overlaps Czech heavily (both descend from a shared diacritic
|
||||
// tradition) but adds ä/ĺ/ľ/ô/ŕ and drops none — kept as its own page rather
|
||||
// than merged, per the "each language separate" request.
|
||||
static const char* const KB_SLOVAK_CHARS[4][10] = {
|
||||
{ "á", "ä", "č", "ď", "é", "í", "ĺ", "ľ", "ň", "ó" },
|
||||
{ "ô", "ŕ", "š", "ť", "ú", "ý", "ž", ".", ",", "!" },
|
||||
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" },
|
||||
{ "?", "'", "-", "\"", ":", ";", "(", ")", "@", "#" },
|
||||
};
|
||||
static const char* const KB_T9_GROUPS_SLOVAK[9] = {
|
||||
".,!?'-", "áäč", "ďé", "íĺ", "ľň", "óô", "ŕš", "ťú", "ýž"
|
||||
};
|
||||
|
||||
// German: only 4 non-ASCII letters (ä ö ü ß) — the smallest of these
|
||||
// keyboards, still filled to a full page rather than left sparse.
|
||||
static const char* const KB_GERMAN_CHARS[4][10] = {
|
||||
{ "ä", "ö", "ü", "ß", ".", ",", "!", "?", "'", "-" },
|
||||
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" },
|
||||
{ "\"", ":", ";", "(", ")", "@", "#", "&", "*", "_" },
|
||||
{ "+", "=", "/", "\\", "~", "<", ">", "[", "]", "{" },
|
||||
};
|
||||
static const char* const KB_T9_GROUPS_GERMAN[9] = {
|
||||
".,!?'-", "äö", "üß", ";()", "@#&*", "_+=/", "\\~<>", "[]{}", "|^$%"
|
||||
};
|
||||
|
||||
// French: à â ç é è ê ë î ï ô ù û ü ÿ œ. ÿ's uppercase (Ÿ, U+0178) is the one
|
||||
// case-shift exception outside the Latin-1 flat -0x20 rule — see
|
||||
// kbApplyCapsUtf8. œ is Latin Extended-A (adjacent-pair rule, verified).
|
||||
static const char* const KB_FRENCH_CHARS[4][10] = {
|
||||
{ "à", "â", "ç", "é", "è", "ê", "ë", "î", "ï", "ô" },
|
||||
{ "ù", "û", "ü", "ÿ", "œ", ".", ",", "!", "?", "'" },
|
||||
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" },
|
||||
{ "-", "\"", ":", ";", "(", ")", "@", "#", "&", "*" },
|
||||
};
|
||||
static const char* const KB_T9_GROUPS_FRENCH[9] = {
|
||||
".,!?'-", "àâ", "çé", "èê", "ëî", "ïô", "ùû", "üÿ", "œ"
|
||||
};
|
||||
|
||||
// Spanish: the 5 accented vowels, ñ, and ü (only appears in güe/güi).
|
||||
static const char* const KB_SPANISH_CHARS[4][10] = {
|
||||
{ "á", "é", "í", "ñ", "ó", "ú", "ü", ".", ",", "!" },
|
||||
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" },
|
||||
{ "?", "'", "-", "\"", ":", ";", "(", ")", "@", "#" },
|
||||
{ "&", "*", "_", "+", "=", "/", "\\", "~", "<", ">" },
|
||||
};
|
||||
static const char* const KB_T9_GROUPS_SPANISH[9] = {
|
||||
".,!?'-", "áé", "íñ", "óú", "ü", ";()", "@#&*", "_+=/", "\\~<>"
|
||||
};
|
||||
|
||||
// Portuguese: á à â ã ç é ê í ó ô õ ú (ü dropped from modern orthography).
|
||||
static const char* const KB_PORTUGUESE_CHARS[4][10] = {
|
||||
{ "á", "à", "â", "ã", "ç", "é", "ê", "í", "ó", "ô" },
|
||||
{ "õ", "ú", ".", ",", "!", "?", "'", "-", "\"", ":" },
|
||||
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" },
|
||||
{ ";", "(", ")", "@", "#", "&", "*", "_", "+", "=" },
|
||||
};
|
||||
static const char* const KB_T9_GROUPS_PORTUGUESE[9] = {
|
||||
".,!?'-", "áà", "âã", "çé", "êí", "óô", "õú", ";()", "@#&*"
|
||||
};
|
||||
|
||||
// Nordic: å ä æ ö ø — shared across Danish/Norwegian/Swedish (their
|
||||
// alphabets differ only in which of these 5 each uses), so kept as one page
|
||||
// rather than three near-identical ones.
|
||||
static const char* const KB_NORDIC_CHARS[4][10] = {
|
||||
{ "å", "ä", "æ", "ö", "ø", ".", ",", "!", "?", "'" },
|
||||
{ "1", "2", "3", "4", "5", "6", "7", "8", "9", "0" },
|
||||
{ "-", "\"", ":", ";", "(", ")", "@", "#", "&", "*" },
|
||||
{ "_", "+", "=", "/", "\\", "~", "<", ">", "[", "]" },
|
||||
};
|
||||
static const char* const KB_T9_GROUPS_NORDIC[9] = {
|
||||
".,!?'-", "åä", "æö", "ø", ";()", "@#&*", "_+=/", "\\~<>", "[]{}"
|
||||
};
|
||||
// Buffer cap for typed text, in bytes. Matches MeshCore's MAX_TEXT_LEN
|
||||
// (10*CIPHER_BLOCK_SIZE = 160) so a full-length message can be composed; each
|
||||
@@ -127,17 +213,21 @@ static const int KB_PREVIEW_CAP = 46;
|
||||
// here pairs lower/uppercase differently, so each gets its own rule:
|
||||
// - ASCII a-z and Cyrillic а-я: flat -0x20 codepoint offset. ё/Ё (U+0451/
|
||||
// U+0401) break the Cyrillic pattern by 0x50 and are special-cased.
|
||||
// - Latin-1 Supplement à-þ (U+00E0-00FE, Ext.Latin's ö/ü/é/etc.): also a flat
|
||||
// -0x20 offset, same as ASCII — the block was designed as parallel case
|
||||
// pairs. U+00F7 (÷, division sign) sits in that numeric range but isn't a
|
||||
// letter; excluded. ß (U+00DF) has no simple uppercase in this range
|
||||
// (its uppercase ẞ is U+1E9E, outside Lemon's U+0020-04FF) — left as-is.
|
||||
// - Latin Extended-A (Ext.Latin's ą/č/etc., U+0100-017F): NOT a flat offset
|
||||
// like Latin-1 — this block alternates even=uppercase/odd=lowercase in
|
||||
// adjacent pairs, so lowercase - 1 = uppercase. Verified true for every
|
||||
// character actually in KB_EXTLATIN_CHARS/KB_T9_GROUPS_EXTLATIN; NOT a
|
||||
// universal rule for the whole block (it has a handful of unpaired/
|
||||
// irregular codepoints elsewhere) — recheck before adding more from it.
|
||||
// - Latin-1 Supplement à-þ (U+00E0-00FE, used by every Latin-diacritic
|
||||
// alphabet below — ö/ü/é/á/etc.): also a flat -0x20 offset, same as ASCII —
|
||||
// the block was designed as parallel case pairs. U+00F7 (÷, division sign)
|
||||
// sits in that numeric range but isn't a letter; excluded. ß (U+00DF) has
|
||||
// no simple uppercase in this range (its uppercase ẞ is U+1E9E, outside
|
||||
// Lemon's U+0020-04FF) — left as-is. ÿ (U+00FF, French) is the one letter
|
||||
// in this block whose uppercase Ÿ (U+0178) falls outside it entirely —
|
||||
// special-cased before the range rule.
|
||||
// - Latin Extended-A (ą/č/ĺ/œ/etc., U+0100-017F): NOT a flat offset like
|
||||
// Latin-1 — this block alternates even=uppercase/odd=lowercase in adjacent
|
||||
// pairs, so lowercase - 1 = uppercase. Verified true (against Python's own
|
||||
// str.upper()) for every character actually used across the Polish/Czech/
|
||||
// Slovak/French keyboards below; NOT a universal rule for the whole block
|
||||
// (it has a handful of unpaired/irregular codepoints elsewhere) — recheck
|
||||
// before adding more from it.
|
||||
// - Greek α-ω (U+03B1-03C9): flat -0x20 offset, same shape as Cyrillic/ASCII.
|
||||
// Final sigma ς (U+03C2) is the one exception — it has no uppercase of its
|
||||
// own; -0x20 would land on U+03A2, which is unassigned. It capitalizes to
|
||||
@@ -152,6 +242,7 @@ static void kbApplyCapsUtf8(const char* in, bool caps, char* out, size_t out_siz
|
||||
if (caps) {
|
||||
if (cp == 0x0451) cp = 0x0401; // ё -> Ё
|
||||
else if (cp == 0x03C2) cp = 0x03A3; // ς -> Σ
|
||||
else if (cp == 0x00FF) cp = 0x0178; // ÿ -> Ÿ (French; breaks the à-þ flat -0x20 rule below — Ÿ sits outside Latin-1 Supplement entirely)
|
||||
else if (cp >= 0x0430 && cp <= 0x044F) cp -= 0x20; // а-я -> А-Я
|
||||
else if (cp >= 0x03B1 && cp <= 0x03C9) cp -= 0x20; // α-ω -> Α-Ω
|
||||
else if (cp >= 0x00E0 && cp <= 0x00FE && cp != 0x00F7) cp -= 0x20; // à-þ -> À-Þ
|
||||
@@ -255,9 +346,16 @@ struct KeyboardWidget {
|
||||
const char* cellStr(int r, int c) const {
|
||||
if (pageIsAltAlphabet(page)) {
|
||||
switch (prefs->keyboard_alt_alphabet) {
|
||||
case NodePrefs::KB_ALPHABET_CYRILLIC: return KB_CYRILLIC_CHARS[r][c];
|
||||
case NodePrefs::KB_ALPHABET_GREEK: return KB_GREEK_CHARS[r][c];
|
||||
case NodePrefs::KB_ALPHABET_EXT_LATIN: return KB_EXTLATIN_CHARS[r][c];
|
||||
case NodePrefs::KB_ALPHABET_CYRILLIC: return KB_CYRILLIC_CHARS[r][c];
|
||||
case NodePrefs::KB_ALPHABET_GREEK: return KB_GREEK_CHARS[r][c];
|
||||
case NodePrefs::KB_ALPHABET_POLISH: return KB_POLISH_CHARS[r][c];
|
||||
case NodePrefs::KB_ALPHABET_CZECH: return KB_CZECH_CHARS[r][c];
|
||||
case NodePrefs::KB_ALPHABET_SLOVAK: return KB_SLOVAK_CHARS[r][c];
|
||||
case NodePrefs::KB_ALPHABET_GERMAN: return KB_GERMAN_CHARS[r][c];
|
||||
case NodePrefs::KB_ALPHABET_FRENCH: return KB_FRENCH_CHARS[r][c];
|
||||
case NodePrefs::KB_ALPHABET_SPANISH: return KB_SPANISH_CHARS[r][c];
|
||||
case NodePrefs::KB_ALPHABET_PORTUGUESE: return KB_PORTUGUESE_CHARS[r][c];
|
||||
case NodePrefs::KB_ALPHABET_NORDIC: return KB_NORDIC_CHARS[r][c];
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
@@ -271,9 +369,16 @@ struct KeyboardWidget {
|
||||
const char* t9GroupStr(int cell) const {
|
||||
if (pageIsAltAlphabet(page)) {
|
||||
switch (prefs->keyboard_alt_alphabet) {
|
||||
case NodePrefs::KB_ALPHABET_CYRILLIC: return KB_T9_GROUPS_CYRILLIC[cell];
|
||||
case NodePrefs::KB_ALPHABET_GREEK: return KB_T9_GROUPS_GREEK[cell];
|
||||
case NodePrefs::KB_ALPHABET_EXT_LATIN: return KB_T9_GROUPS_EXTLATIN[cell];
|
||||
case NodePrefs::KB_ALPHABET_CYRILLIC: return KB_T9_GROUPS_CYRILLIC[cell];
|
||||
case NodePrefs::KB_ALPHABET_GREEK: return KB_T9_GROUPS_GREEK[cell];
|
||||
case NodePrefs::KB_ALPHABET_POLISH: return KB_T9_GROUPS_POLISH[cell];
|
||||
case NodePrefs::KB_ALPHABET_CZECH: return KB_T9_GROUPS_CZECH[cell];
|
||||
case NodePrefs::KB_ALPHABET_SLOVAK: return KB_T9_GROUPS_SLOVAK[cell];
|
||||
case NodePrefs::KB_ALPHABET_GERMAN: return KB_T9_GROUPS_GERMAN[cell];
|
||||
case NodePrefs::KB_ALPHABET_FRENCH: return KB_T9_GROUPS_FRENCH[cell];
|
||||
case NodePrefs::KB_ALPHABET_SPANISH: return KB_T9_GROUPS_SPANISH[cell];
|
||||
case NodePrefs::KB_ALPHABET_PORTUGUESE: return KB_T9_GROUPS_PORTUGUESE[cell];
|
||||
case NodePrefs::KB_ALPHABET_NORDIC: return KB_T9_GROUPS_NORDIC[cell];
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
@@ -287,9 +392,16 @@ struct KeyboardWidget {
|
||||
// symbols pages too, where render() doesn't force Lemon on (see render()).
|
||||
static const char* altAlphabetHint(uint8_t alt) {
|
||||
switch (alt) {
|
||||
case NodePrefs::KB_ALPHABET_CYRILLIC: return "CY";
|
||||
case NodePrefs::KB_ALPHABET_GREEK: return "GR";
|
||||
case NodePrefs::KB_ALPHABET_EXT_LATIN: return "EL";
|
||||
case NodePrefs::KB_ALPHABET_CYRILLIC: return "CY";
|
||||
case NodePrefs::KB_ALPHABET_GREEK: return "GR";
|
||||
case NodePrefs::KB_ALPHABET_POLISH: return "PL";
|
||||
case NodePrefs::KB_ALPHABET_CZECH: return "CZ";
|
||||
case NodePrefs::KB_ALPHABET_SLOVAK: return "SK";
|
||||
case NodePrefs::KB_ALPHABET_GERMAN: return "DE";
|
||||
case NodePrefs::KB_ALPHABET_FRENCH: return "FR";
|
||||
case NodePrefs::KB_ALPHABET_SPANISH: return "ES";
|
||||
case NodePrefs::KB_ALPHABET_PORTUGUESE: return "PT";
|
||||
case NodePrefs::KB_ALPHABET_NORDIC: return "ND";
|
||||
default: return "?";
|
||||
}
|
||||
}
|
||||
@@ -353,7 +465,16 @@ struct KeyboardWidget {
|
||||
const int preview_h = display.height() - kb_h - display.sepH();
|
||||
const int prev_lines = (preview_h / lh) > 1 ? (preview_h / lh) : 1;
|
||||
const int sep_y = prev_lines * lh;
|
||||
const int chars_y = sep_y + display.sepH();
|
||||
// Lemon's tallest glyphs (accented capitals like Ć/Š/Ž, needed once caps
|
||||
// is on) draw up to 3px above the nominal cursor y on SH1106 — its Lemon
|
||||
// baseline offset is tuned for the shorter plain-ASCII/Cyrillic/Greek
|
||||
// glyphs this keyboard used before the per-language Latin alphabets, and
|
||||
// wasn't tall enough for these. Padding the first char row down by 3px
|
||||
// (only while Lemon is actually in use) gives every accented glyph enough
|
||||
// headroom that it can't bleed into the separator bar above; harmless on
|
||||
// e-ink, which already has more headroom than it needs here.
|
||||
const int lemon_pad = want_lemon ? 3 : 0;
|
||||
const int chars_y = sep_y + display.sepH() + lemon_pad;
|
||||
const int cell_h = (display.height() - chars_y) / (rows + 1);
|
||||
const int spec_y = chars_y + rows * cell_h;
|
||||
const int spec_w = display.width() / KB_SPECIAL;
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#pragma once
|
||||
// Message history store for QuickMsgScreen: two RAM ring buffers (channel + DM)
|
||||
// Message history store for MessagesScreen: two RAM ring buffers (channel + DM)
|
||||
// with their per-entry delivery state (channel "relayed into mesh" echo, DM
|
||||
// end-to-end ACK + auto-resend) and the per-channel unread counters. Pure
|
||||
// storage + queries — no UI/phase state lives here. The screen keeps selection,
|
||||
// scroll, the unread "viewing session" bookkeeping, the room-login table, and
|
||||
// all rendering, and reaches entries through the accessors below.
|
||||
//
|
||||
// Single-TU fragment: included by UITask.cpp before QuickMsgScreen.h. AckState,
|
||||
// Single-TU fragment: included by UITask.cpp before MessagesScreen.h. AckState,
|
||||
// MSG_TEXT_BUF and the two entry structs are file-scope (not nested) so the
|
||||
// phase machine in QuickMsgScreen keeps referring to them unqualified.
|
||||
// phase machine in MessagesScreen keeps referring to them unqualified.
|
||||
|
||||
// Outgoing-message delivery state. DM: a real end-to-end ACK (✓ delivered to
|
||||
// the recipient). Channel: only a "relayed into mesh" echo from a repeater (no
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
|
||||
#include "NavView.h" // navigate to a location shared inside a message
|
||||
#include "icons.h" // scalable mini-icons (delivery markers)
|
||||
#include "ChannelsView.h" // on-device channel add/edit form (Channels tab)
|
||||
|
||||
class QuickMsgScreen : public UIScreen {
|
||||
class MessagesScreen : public UIScreen {
|
||||
UITask* _task;
|
||||
|
||||
enum Phase { MODE_SELECT, CONTACT_PICK, DM_HIST, MSG_PICK, CHANNEL_PICK, CHANNEL_HIST, KEYBOARD };
|
||||
@@ -551,8 +552,13 @@ class QuickMsgScreen : public UIScreen {
|
||||
&NodePrefs::DmMelodyEntry::slot, slot);
|
||||
}
|
||||
|
||||
// On-device channel Add/Edit form (Channels tab) — owned by this screen and
|
||||
// delegated to while active(), the same relationship WaypointsView has with
|
||||
// TrailScreen.
|
||||
ChannelsView _ch_view;
|
||||
|
||||
public:
|
||||
QuickMsgScreen(UITask* task, KeyboardWidget* kb)
|
||||
MessagesScreen(UITask* task, KeyboardWidget* kb)
|
||||
: _task(task), _kb(kb), _phase(MODE_SELECT), _mode_sel(0),
|
||||
_contact_sel(0), _contact_scroll(0), _num_contacts(0), _room_mode(false), _login_mode(false),
|
||||
_channel_sel(0), _channel_scroll(0), _num_channels(0),
|
||||
@@ -561,10 +567,24 @@ public:
|
||||
_hist_sel(0), _hist_scroll(0),
|
||||
_unread_at_entry(0), _viewing_max_seen(0),
|
||||
_dm_hist_sel(-1), _dm_hist_scroll(0),
|
||||
_ctx_dirty(false), _pin_picker_active(false), _dm_direct_entry(false), _reply_mode(false) {
|
||||
_ctx_dirty(false), _pin_picker_active(false), _dm_direct_entry(false), _reply_mode(false),
|
||||
_ch_view(task) {
|
||||
// The history rings + per-channel unread counters init in MessageHistory.
|
||||
}
|
||||
|
||||
// First free channel slot (existing config or blank name), or -1 if full.
|
||||
int findFreeChannelSlot() const {
|
||||
for (int i = 0; i < MAX_GROUP_CHANNELS; i++) {
|
||||
ChannelDetails ch;
|
||||
if (!the_mesh.getChannel(i, ch) || ch.name[0] == '\0') return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// CHANNEL_PICK row count including the synthetic "+ Add channel" row
|
||||
// (suppressed while picking a channel for the bot).
|
||||
int channelPickTotal() const { return _num_channels + (_pick_bot_channel ? 0 : 1); }
|
||||
|
||||
// Public entry points (routed from MyMesh / the bot via UITask) — thin
|
||||
// forwarders to the history store. addChannelMsg computes the "viewing" flag
|
||||
// (a phase-machine fact the store can't see) and returns the ring position so
|
||||
@@ -733,6 +753,7 @@ public:
|
||||
_dm_direct_entry = false;
|
||||
_unread_at_entry = 0;
|
||||
_viewing_max_seen = 0;
|
||||
_ch_view.reset();
|
||||
}
|
||||
|
||||
// Recent DM contacts, newest first, deduped (forwarded to the history store).
|
||||
@@ -855,6 +876,9 @@ public:
|
||||
display.setTextSize(1);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
|
||||
// Channel Add/Edit form owns the screen while active.
|
||||
if (_ch_view.active()) return _ch_view.render(display);
|
||||
|
||||
// Navigate-to-location view sits over everything else while active.
|
||||
if (_nav_active) { renderNav(display); return 1000; }
|
||||
|
||||
@@ -914,13 +938,23 @@ public:
|
||||
} else if (_phase == CHANNEL_PICK) {
|
||||
display.drawCenteredHeader("SELECT CHANNEL", true, _ctx_menu.active);
|
||||
|
||||
if (_num_channels == 0) {
|
||||
// "+ Add channel" is a synthetic trailing row — suppressed while picking
|
||||
// a channel for the bot, so that picker's list stays unchanged.
|
||||
bool show_add = !_pick_bot_channel;
|
||||
int total = _num_channels + (show_add ? 1 : 0);
|
||||
|
||||
if (total == 0) {
|
||||
display.drawTextCentered(display.width()/2, display.height()/2, "No channels");
|
||||
return 5000;
|
||||
}
|
||||
|
||||
drawList(display, _num_channels, _channel_sel, _channel_scroll, [&](int list_idx, int y, bool sel, int reserve) {
|
||||
drawList(display, total, _channel_sel, _channel_scroll, [&](int list_idx, int y, bool sel, int reserve) {
|
||||
drawRowSelection(display, y, sel, reserve);
|
||||
if (list_idx == _num_channels) { // the synthetic "Add" row
|
||||
display.setCursor(2, y); display.print("+ Add channel");
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
return;
|
||||
}
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(_channel_indices[list_idx], ch)) {
|
||||
uint8_t unread = _history.chUnread(_channel_indices[list_idx]);
|
||||
@@ -1242,6 +1276,9 @@ public:
|
||||
}
|
||||
|
||||
bool handleInput(char c) override {
|
||||
// Channel Add/Edit form consumes all input while active.
|
||||
if (_ch_view.active()) return _ch_view.handleInput(c);
|
||||
|
||||
// Navigate view: any back key returns to the message it was opened from.
|
||||
if (_nav_active) {
|
||||
if (c == KEY_CANCEL || c == KEY_ENTER || c == KEY_CONTEXT_MENU) _nav_active = false;
|
||||
@@ -1541,12 +1578,20 @@ public:
|
||||
int cleared = (int)_history.chUnread(ch_idx);
|
||||
_history.setChUnread(ch_idx, 0);
|
||||
markReadAlert(cleared);
|
||||
} else if (sel == 4) { // Edit
|
||||
ChannelDetails ch;
|
||||
if (the_mesh.getChannel(ch_idx, ch)) _ch_view.openEdit(ch_idx, ch.name);
|
||||
} else if (sel == 5) { // Delete
|
||||
ChannelDetails ch;
|
||||
memset(&ch, 0, sizeof(ch));
|
||||
the_mesh.setChannelLocal(ch_idx, ch);
|
||||
_task->showAlert("Channel deleted", 1000);
|
||||
}
|
||||
// sel 1/2/3 already handled by LEFT/RIGHT; ENTER just closes.
|
||||
}
|
||||
if (res != PopupMenu::NONE) {
|
||||
_task->savePrefsIfDirty(_ctx_dirty);
|
||||
// Apply any Fav change to the visible list now that the menu is done.
|
||||
// Apply any Fav/Edit/Delete change to the visible list now that the menu is done.
|
||||
buildChannelList();
|
||||
if (_channel_sel >= _num_channels) _channel_sel = _num_channels > 0 ? _num_channels - 1 : 0;
|
||||
}
|
||||
@@ -1558,9 +1603,17 @@ public:
|
||||
return true;
|
||||
}
|
||||
// drawList() reclamps _channel_scroll from _channel_sel every render.
|
||||
if (c == KEY_UP && _num_channels > 0) { _channel_sel = (_channel_sel > 0) ? _channel_sel - 1 : _num_channels - 1; return true; }
|
||||
if (c == KEY_DOWN && _num_channels > 0) { _channel_sel = (_channel_sel < _num_channels - 1) ? _channel_sel + 1 : 0; return true; }
|
||||
if (c == KEY_ENTER && _num_channels > 0) {
|
||||
{ int total = channelPickTotal();
|
||||
if (c == KEY_UP && total > 0) { _channel_sel = (_channel_sel > 0) ? _channel_sel - 1 : total - 1; return true; }
|
||||
if (c == KEY_DOWN && total > 0) { _channel_sel = (_channel_sel < total - 1) ? _channel_sel + 1 : 0; return true; }
|
||||
}
|
||||
if (c == KEY_ENTER && _channel_sel == _num_channels && !_pick_bot_channel) {
|
||||
int idx = findFreeChannelSlot();
|
||||
if (idx < 0) _task->showAlert("Channels full", 1200);
|
||||
else _ch_view.openAdd(idx);
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _num_channels > 0 && _channel_sel < _num_channels) {
|
||||
_sel_channel_idx = _channel_indices[_channel_sel];
|
||||
if (_pick_target) { commitPickTargetChannel(_sel_channel_idx); return true; }
|
||||
if (_pick_bot_channel) { commitPickBotChannel(_sel_channel_idx); return true; }
|
||||
@@ -1577,7 +1630,7 @@ public:
|
||||
if (_share_mode) beginShareCompose(true);
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_CONTEXT_MENU && _num_channels > 0) {
|
||||
if (c == KEY_CONTEXT_MENU && _num_channels > 0 && _channel_sel < _num_channels) {
|
||||
uint8_t ch_idx = _channel_indices[_channel_sel];
|
||||
_ctx_ch_idx = ch_idx; // freeze the menu's target channel
|
||||
static const char* NOTIF_LABELS[] = { "default", "OFF", "ON" };
|
||||
@@ -1589,11 +1642,13 @@ public:
|
||||
{ NodePrefs* p2 = _task->getNodePrefs();
|
||||
bool is_fav = p2 && (p2->ch_fav_bitmask & (1ULL << ch_idx));
|
||||
snprintf(_ctx_ch_fav_item, sizeof(_ctx_ch_fav_item), is_fav ? "Fav: yes" : "Fav: no"); }
|
||||
_ctx_menu.begin("Channel options", 4);
|
||||
_ctx_menu.begin("Channel options", 6);
|
||||
_ctx_menu.addItem("Mark all read");
|
||||
_ctx_menu.addItem(_ctx_notif_item);
|
||||
_ctx_menu.addItem(_ctx_melody_item);
|
||||
_ctx_menu.addItem(_ctx_ch_fav_item);
|
||||
_ctx_menu.addItem("Edit");
|
||||
_ctx_menu.addItem("Delete");
|
||||
_ctx_dirty = false;
|
||||
return true;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
#include "../GeoUtils.h"
|
||||
#include "NavView.h"
|
||||
#include "TabBar.h"
|
||||
|
||||
// ── Nearby Nodes ──────────────────────────────────────────────────────────────
|
||||
// One list / detail / action-menu interaction path over two sources:
|
||||
@@ -692,56 +693,12 @@ class NearbyScreen : public UIScreen {
|
||||
return false;
|
||||
}
|
||||
|
||||
// One filter tab: short label; the active one is an inverted pill (same look as
|
||||
// a selected row) so it reads as "you are here" in the tab strip.
|
||||
void drawTab(DisplayDriver& display, int x, int w, const char* label, bool active) {
|
||||
int lh = display.getLineHeight();
|
||||
if (active) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(x, 0, w, lh + 1);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.drawTextCentered(x + w / 2, 0, label);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
// Header as a circular filter tab bar: the active filter sits centred as a
|
||||
// filled pill, neighbours fan out either side and wrap around (the tab before
|
||||
// "All" is "Snsr", and vice-versa), matching the wrap-around LEFT/RIGHT cycle.
|
||||
// Only whole tabs are drawn — a tab that would spill past a screen edge is
|
||||
// skipped rather than clipped, so labels never wrap onto a second line.
|
||||
// Header as a circular filter tab bar (shared geometry — see TabBar.h): the
|
||||
// active filter sits centred as a filled pill, neighbours fan out either
|
||||
// side and wrap around (the tab before "All" is "Snsr", and vice-versa),
|
||||
// matching the wrap-around LEFT/RIGHT cycle.
|
||||
void drawFilterTabs(DisplayDriver& display) {
|
||||
const int pad = 3, gap = 2;
|
||||
int w[F_COUNT];
|
||||
for (int i = 0; i < F_COUNT; i++)
|
||||
w[i] = display.getTextWidth(FILTER_LABELS[i]) + pad * 2;
|
||||
|
||||
int ax = display.width() / 2 - w[_filter] / 2; // active tab centred
|
||||
drawTab(display, ax, w[_filter], FILTER_LABELS[_filter], true);
|
||||
|
||||
int lx = ax - gap; // next left tab's right edge
|
||||
int rx = ax + w[_filter] + gap; // next right tab's left edge
|
||||
int rx_limit = display.width() - display.menuHintWidth(); // leave room for the ≡ hint
|
||||
bool lfit = true, rfit = true;
|
||||
for (int k = 1; k <= F_COUNT / 2 && (lfit || rfit); k++) {
|
||||
int li = (_filter - k + F_COUNT) % F_COUNT;
|
||||
int ri = (_filter + k) % F_COUNT;
|
||||
if (lfit) {
|
||||
int x = lx - w[li];
|
||||
if (x >= 0) { drawTab(display, x, w[li], FILTER_LABELS[li], false); lx = x - gap; }
|
||||
else lfit = false;
|
||||
}
|
||||
// Skip the right side when it lands on the same tab as the left (the single
|
||||
// opposite tab on an even count) so it isn't drawn twice.
|
||||
if (rfit && ri != li) {
|
||||
if (rx + w[ri] <= rx_limit) { drawTab(display, rx, w[ri], FILTER_LABELS[ri], false); rx += w[ri] + gap; }
|
||||
else rfit = false;
|
||||
}
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH());
|
||||
tabbar::draw(display, FILTER_LABELS, F_COUNT, _filter, display.menuHintWidth());
|
||||
display.drawContextMenuHint(DisplayDriver::LIGHT, ctxMenuOpen()); // Nodes list has a Hold-Enter menu
|
||||
}
|
||||
|
||||
|
||||
101
examples/companion_radio/ui-new/TabBar.h
Normal file
101
examples/companion_radio/ui-new/TabBar.h
Normal file
@@ -0,0 +1,101 @@
|
||||
#pragma once
|
||||
// Shared circular tab-bar header, extracted after NearbyScreen and BotScreen
|
||||
// had each independently grown byte-identical rendering code for the same
|
||||
// "active tab centred as a pill, neighbours fan out either side and wrap
|
||||
// around" layout (see NearbyScreen::drawFilterTabs / BotScreen::drawTabBar,
|
||||
// pre-extraction). A third screen (AdminScreen) needing the same thing was
|
||||
// the rule-of-three trigger to finally share it.
|
||||
//
|
||||
// Tab-switching itself (LEFT/RIGHT cycling the active index) is NOT part of
|
||||
// this helper -- each screen's cycle has different side effects (refresh a
|
||||
// list, reset a row selection, ...), so that one-liner stays inline per screen.
|
||||
|
||||
namespace tabbar {
|
||||
|
||||
// One tab: short label; the active one is an inverted pill (same look as a
|
||||
// selected row) so it reads as "you are here" in the tab strip.
|
||||
inline void drawPill(DisplayDriver& display, int x, int w, const char* label, bool active) {
|
||||
int lh = display.getLineHeight();
|
||||
if (active) {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(x, 0, w, lh + 1);
|
||||
display.setColor(DisplayDriver::DARK);
|
||||
} else {
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
display.drawTextCentered(x + w / 2, 0, label);
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
}
|
||||
|
||||
// Truncate `label` to fit within `max_w` pixels of text (no padding), the same
|
||||
// shrink-and-append-"..." approach as DisplayDriver::drawTextEllipsized (which
|
||||
// can't be reused directly here: it draws immediately at a fixed x, but a tab
|
||||
// pill needs the truncated width known *before* drawing, to size/centre the
|
||||
// pill). Returns the resulting text's pixel width; writes the (possibly
|
||||
// truncated) text into `out`.
|
||||
inline int ellipsize(DisplayDriver& display, const char* label, int max_w, char* out, size_t out_sz) {
|
||||
strncpy(out, label, out_sz - 1);
|
||||
out[out_sz - 1] = '\0';
|
||||
int w = display.getTextWidth(out);
|
||||
if (w <= max_w) return w;
|
||||
|
||||
const int ellipsis_w = display.getTextWidth("...");
|
||||
int len = (int)strlen(out);
|
||||
while (len > 0 && display.getTextWidth(out) > max_w - ellipsis_w) out[--len] = '\0';
|
||||
// Strip an orphaned UTF-8 lead byte left by the byte-at-a-time trim above.
|
||||
while (len > 0 && ((uint8_t)out[len - 1] & 0xC0) == 0xC0) out[--len] = '\0';
|
||||
strcat(out, "...");
|
||||
return display.getTextWidth(out);
|
||||
}
|
||||
|
||||
// Header as a circular tab bar: the active tab sits centred as a filled pill,
|
||||
// neighbours fan out either side and wrap around. A neighbour that doesn't
|
||||
// fully fit in the space remaining on its side is drawn truncated with an
|
||||
// ellipsis instead of being skipped, so the immediate left/right tabs stay
|
||||
// visible (just shortened) rather than vanishing on a narrow display or a
|
||||
// long active label -- only once even an ellipsis wouldn't fit does that side
|
||||
// stop. `right_reserve` keeps any trailing decoration the caller draws
|
||||
// afterwards (a context-menu hint, a counter) clear of the rightmost tab.
|
||||
// Also draws the header separator line.
|
||||
inline void draw(DisplayDriver& display, const char* const* labels, int count, int active, int right_reserve = 0) {
|
||||
const int pad = 3, gap = 2;
|
||||
const int min_w = pad * 2 + 6; // smallest pill worth drawing (padding + a couple px of text)
|
||||
char buf[24];
|
||||
|
||||
int aw = display.getTextWidth(labels[active]) + pad * 2;
|
||||
int ax = display.width() / 2 - aw / 2;
|
||||
drawPill(display, ax, aw, labels[active], true);
|
||||
|
||||
int lx = ax - gap;
|
||||
int rx = ax + aw + gap;
|
||||
int rx_limit = display.width() - right_reserve;
|
||||
bool lfit = true, rfit = true;
|
||||
for (int k = 1; k <= count / 2 && (lfit || rfit); k++) {
|
||||
int li = (active - k + count) % count;
|
||||
int ri = (active + k) % count;
|
||||
if (lfit) {
|
||||
int avail = lx; // space from x=0 to the running left edge
|
||||
if (avail >= min_w) {
|
||||
int tw = ellipsize(display, labels[li], avail - pad * 2, buf, sizeof(buf));
|
||||
int w = tw + pad * 2;
|
||||
drawPill(display, lx - w, w, buf, false);
|
||||
lx = lx - w - gap;
|
||||
} else lfit = false;
|
||||
}
|
||||
// Skip the right side when it lands on the same tab as the left (the
|
||||
// single opposite tab on an even count) so it isn't drawn twice.
|
||||
if (rfit && ri != li) {
|
||||
int avail = rx_limit - rx;
|
||||
if (avail >= min_w) {
|
||||
int tw = ellipsize(display, labels[ri], avail - pad * 2, buf, sizeof(buf));
|
||||
int w = tw + pad * 2;
|
||||
drawPill(display, rx, w, buf, false);
|
||||
rx += w + gap;
|
||||
} else rfit = false;
|
||||
}
|
||||
}
|
||||
display.setColor(DisplayDriver::LIGHT);
|
||||
display.fillRect(0, display.headerH() - display.sepH(), display.width(), display.sepH());
|
||||
}
|
||||
|
||||
} // namespace tabbar
|
||||
@@ -14,7 +14,7 @@ class ToolsScreen : public UIScreen {
|
||||
|
||||
enum Action {
|
||||
ACT_NEARBY, ACT_LIVESHARE, ACT_TRAIL, ACT_LOCATOR, ACT_COMPASS,
|
||||
ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER,
|
||||
ACT_BOT, ACT_AUTOADVERT, ACT_REPEATER, ACT_ADMIN,
|
||||
ACT_CLOCK, ACT_RINGTONE, ACT_DIAGNOSTICS
|
||||
};
|
||||
struct Tool { const char* label; const MiniIcon* icon; Action action; };
|
||||
@@ -49,6 +49,7 @@ class ToolsScreen : public UIScreen {
|
||||
case ACT_BOT: _task->gotoBotScreen(); break;
|
||||
case ACT_AUTOADVERT: _task->gotoAutoAdvertScreen(); break;
|
||||
case ACT_REPEATER: _task->gotoRepeaterScreen(); break;
|
||||
case ACT_ADMIN: _task->gotoAdminScreen(); break;
|
||||
case ACT_CLOCK: _task->gotoClockTools(); break;
|
||||
case ACT_RINGTONE: _task->gotoRingtoneEditor(); break;
|
||||
case ACT_DIAGNOSTICS: _task->gotoDiagnosticsScreen(); break;
|
||||
@@ -121,6 +122,7 @@ const ToolsScreen::Tool ToolsScreen::COMMS_TOOLS[] = {
|
||||
{ "Auto-Reply Bot", &ICON_BOT, ACT_BOT },
|
||||
{ "Auto-Advert", &ICON_ADVERT, ACT_AUTOADVERT },
|
||||
{ "Repeater", &ICON_REPEATER, ACT_REPEATER },
|
||||
{ "Admin", &ICON_GEAR, ACT_ADMIN },
|
||||
};
|
||||
const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = {
|
||||
{ "Clock Tools", &ICON_ALARM, ACT_CLOCK },
|
||||
@@ -129,6 +131,6 @@ const ToolsScreen::Tool ToolsScreen::SYSTEM_TOOLS[] = {
|
||||
};
|
||||
const ToolsScreen::Section ToolsScreen::SECTIONS[] = {
|
||||
{ "Location", &ICON_MAP_CONTACT, LOCATION_TOOLS, 5 },
|
||||
{ "Comms", &ICON_ADVERT, COMMS_TOOLS, 3 },
|
||||
{ "Comms", &ICON_ADVERT, COMMS_TOOLS, 4 },
|
||||
{ "System", &ICON_GEAR, SYSTEM_TOOLS, 3 },
|
||||
};
|
||||
|
||||
@@ -130,12 +130,13 @@ static const int QUICK_MSGS_MAX = 10;
|
||||
#include "FullscreenMsgView.h"
|
||||
#include "SensorPlaceholders.h"
|
||||
#include "SettingsScreen.h"
|
||||
#include "MessageHistory.h" // RAM history rings (DM + channel) used by QuickMsgScreen
|
||||
#include "QuickMsgScreen.h"
|
||||
#include "MessageHistory.h" // RAM history rings (DM + channel) used by MessagesScreen
|
||||
#include "MessagesScreen.h"
|
||||
|
||||
// ── Custom screens (separate files to ease upstream merges) ───────────────────
|
||||
#include "RingtoneEditorScreen.h"
|
||||
#include "BotScreen.h"
|
||||
#include "AdminScreen.h"
|
||||
#include "NearbyScreen.h"
|
||||
#include "DashboardConfigScreen.h"
|
||||
#include "AutoAdvertScreen.h"
|
||||
@@ -1345,7 +1346,7 @@ public:
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _page == HomePage::QUICK_MSG) {
|
||||
_task->gotoQuickMsgScreen();
|
||||
_task->gotoMessagesScreen();
|
||||
return true;
|
||||
}
|
||||
if (c == KEY_ENTER && _page == HomePage::SHUTDOWN) {
|
||||
@@ -1442,10 +1443,11 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
splash = new SplashScreen(this);
|
||||
home = new HomeScreen(this, &rtc_clock, sensors, node_prefs);
|
||||
settings = new SettingsScreen(this, &_kb);
|
||||
quick_msg = new QuickMsgScreen(this, &_kb);
|
||||
messages_screen = new MessagesScreen(this, &_kb);
|
||||
tools_screen = new ToolsScreen(this);
|
||||
ringtone_edit = new RingtoneEditorScreen(this, node_prefs);
|
||||
bot_screen = new BotScreen(this, node_prefs, &_kb);
|
||||
admin_screen = new AdminScreen(this);
|
||||
nearby_screen = new NearbyScreen(this);
|
||||
dashboard_config = new DashboardConfigScreen(this, node_prefs);
|
||||
auto_advert_screen = new AutoAdvertScreen(this, node_prefs);
|
||||
@@ -1467,6 +1469,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no
|
||||
void UITask::gotoSettingsScreen() { setCurrScreen(settings); }
|
||||
void UITask::gotoToolsScreen() { setCurrScreen(tools_screen); }
|
||||
void UITask::gotoBotScreen() { setCurrScreen(bot_screen); }
|
||||
void UITask::gotoAdminScreen() { setCurrScreen(admin_screen); } // AdminScreen::onShow() resets it
|
||||
void UITask::gotoNearbyScreen() { setCurrScreen(nearby_screen); }
|
||||
void UITask::gotoDashboardConfig() { setCurrScreen(dashboard_config); }
|
||||
void UITask::gotoTrailScreen() { setCurrScreen(trail_screen); }
|
||||
@@ -1667,60 +1670,64 @@ bool UITask::isMelodyPlaying() {
|
||||
#endif
|
||||
}
|
||||
|
||||
void UITask::gotoQuickMsgScreen() {
|
||||
((QuickMsgScreen*)quick_msg)->reset();
|
||||
setCurrScreen(quick_msg);
|
||||
void UITask::gotoMessagesScreen() {
|
||||
((MessagesScreen*)messages_screen)->reset();
|
||||
setCurrScreen(messages_screen);
|
||||
}
|
||||
|
||||
void UITask::openContactDM(const ContactInfo& ci) {
|
||||
((QuickMsgScreen*)quick_msg)->reset();
|
||||
((QuickMsgScreen*)quick_msg)->enterDM(ci);
|
||||
setCurrScreen(quick_msg);
|
||||
((MessagesScreen*)messages_screen)->reset();
|
||||
((MessagesScreen*)messages_screen)->enterDM(ci);
|
||||
setCurrScreen(messages_screen);
|
||||
}
|
||||
|
||||
void UITask::shareToMessage(const char* text) {
|
||||
((QuickMsgScreen*)quick_msg)->startShare(text);
|
||||
setCurrScreen(quick_msg);
|
||||
((MessagesScreen*)messages_screen)->startShare(text);
|
||||
setCurrScreen(messages_screen);
|
||||
}
|
||||
|
||||
void UITask::pickLocShareTarget() {
|
||||
((QuickMsgScreen*)quick_msg)->startPickTarget();
|
||||
setCurrScreen(quick_msg);
|
||||
((MessagesScreen*)messages_screen)->startPickTarget();
|
||||
setCurrScreen(messages_screen);
|
||||
}
|
||||
|
||||
void UITask::pickBotChannelTarget() {
|
||||
((QuickMsgScreen*)quick_msg)->startPickBotChannel();
|
||||
setCurrScreen(quick_msg);
|
||||
((MessagesScreen*)messages_screen)->startPickBotChannel();
|
||||
setCurrScreen(messages_screen);
|
||||
}
|
||||
|
||||
void UITask::pickBotRoomTarget() {
|
||||
((QuickMsgScreen*)quick_msg)->startPickBotRoom();
|
||||
setCurrScreen(quick_msg);
|
||||
((MessagesScreen*)messages_screen)->startPickBotRoom();
|
||||
setCurrScreen(messages_screen);
|
||||
}
|
||||
|
||||
int UITask::getRecentDMContacts(uint8_t out[][NodePrefs::FAVOURITE_PREFIX_LEN], int max) const {
|
||||
return ((QuickMsgScreen*)quick_msg)->getRecentDMContacts(out, max);
|
||||
return ((MessagesScreen*)messages_screen)->getRecentDMContacts(out, max);
|
||||
}
|
||||
|
||||
void UITask::addChannelMsg(uint8_t channel_idx, const char* text, uint32_t timestamp) {
|
||||
_last_notif_ch_idx = (int)channel_idx;
|
||||
((QuickMsgScreen*)quick_msg)->addChannelMsg(channel_idx, text, timestamp);
|
||||
((MessagesScreen*)messages_screen)->addChannelMsg(channel_idx, text, timestamp);
|
||||
}
|
||||
|
||||
int UITask::getChannelUnreadCount() const {
|
||||
return ((QuickMsgScreen*)quick_msg)->getTotalChannelUnread();
|
||||
return ((MessagesScreen*)messages_screen)->getTotalChannelUnread();
|
||||
}
|
||||
|
||||
void UITask::onMsgAck(uint32_t ack_crc) {
|
||||
((QuickMsgScreen*)quick_msg)->markDmDelivered(ack_crc);
|
||||
((MessagesScreen*)messages_screen)->markDmDelivered(ack_crc);
|
||||
}
|
||||
|
||||
void UITask::onChannelRelayed(uint32_t seq) {
|
||||
((QuickMsgScreen*)quick_msg)->markChannelRelayed(seq);
|
||||
((MessagesScreen*)messages_screen)->markChannelRelayed(seq);
|
||||
}
|
||||
|
||||
void UITask::onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) {
|
||||
((QuickMsgScreen*)quick_msg)->onRoomLoginResult(pub_key, success, permissions);
|
||||
// Only one on-device login can be in flight at a time (MyMesh::ui_pending_login
|
||||
// is a single slot) -- route the result to whichever of the two screens that
|
||||
// can trigger a login is currently active, rather than always MessagesScreen.
|
||||
if (curr == admin_screen) ((AdminScreen*)admin_screen)->onRoomLoginResult(pub_key, success, permissions);
|
||||
else ((MessagesScreen*)messages_screen)->onRoomLoginResult(pub_key, success, permissions);
|
||||
// Unlike the keypress-driven showAlert() calls elsewhere, this fires from a
|
||||
// background mesh response with no keypress to schedule a redraw — without
|
||||
// forcing one, the alert's short expiry can lapse before the next scheduled
|
||||
@@ -1728,8 +1735,13 @@ void UITask::onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t per
|
||||
_next_refresh = 0;
|
||||
}
|
||||
|
||||
void UITask::onAdminReply(const uint8_t* pub_key, const char* text) {
|
||||
((AdminScreen*)admin_screen)->onAdminReply(pub_key, text);
|
||||
_next_refresh = 0; // same reasoning as onRoomLoginResult above
|
||||
}
|
||||
|
||||
void UITask::addDMMsg(const uint8_t* pub_key, bool outgoing, const char* text, uint32_t sender_timestamp) {
|
||||
((QuickMsgScreen*)quick_msg)->addDMMsg(pub_key, outgoing, text, sender_timestamp);
|
||||
((MessagesScreen*)messages_screen)->addDMMsg(pub_key, outgoing, text, sender_timestamp);
|
||||
}
|
||||
|
||||
int UITask::getDMUnreadTotal() const {
|
||||
@@ -1790,7 +1802,7 @@ void UITask::msgRead(int msgcount) {
|
||||
if (msgcount == 0) {
|
||||
_room_unread = 0;
|
||||
memset(_dm_unread_table, 0, sizeof(_dm_unread_table));
|
||||
((QuickMsgScreen*)quick_msg)->clearAllChannelUnread();
|
||||
((MessagesScreen*)messages_screen)->clearAllChannelUnread();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2028,7 +2040,7 @@ bool UITask::dequeueKey(char& c) {
|
||||
void UITask::loop() {
|
||||
// Background delivery: resend pending on-device DMs whose ACK timed out, and
|
||||
// finalise the ✗ marker — runs regardless of which screen is active.
|
||||
((QuickMsgScreen*)quick_msg)->tickDmResends();
|
||||
((MessagesScreen*)messages_screen)->tickDmResends();
|
||||
#if UI_HAS_JOYSTICK
|
||||
uint8_t joy_rot = _node_prefs ? _node_prefs->joystick_rotation : JOYSTICK_ROTATION;
|
||||
int ev = user_btn.check();
|
||||
|
||||
@@ -76,10 +76,11 @@ class UITask : public AbstractUITask {
|
||||
UIScreen* splash = nullptr;
|
||||
UIScreen* home = nullptr;
|
||||
UIScreen* settings = nullptr;
|
||||
UIScreen* quick_msg = nullptr;
|
||||
UIScreen* messages_screen = nullptr;
|
||||
UIScreen* tools_screen = nullptr;
|
||||
UIScreen* ringtone_edit = nullptr;
|
||||
UIScreen* bot_screen = nullptr;
|
||||
UIScreen* admin_screen = nullptr;
|
||||
UIScreen* nearby_screen = nullptr;
|
||||
UIScreen* dashboard_config = nullptr;
|
||||
UIScreen* auto_advert_screen = nullptr;
|
||||
@@ -218,7 +219,7 @@ public:
|
||||
uint16_t getBattMilliVolts() const { return _batt_mv > 0 ? _batt_mv : AbstractUITask::getBattMilliVolts(); }
|
||||
void gotoHomeScreen() { setCurrScreen(home); }
|
||||
void gotoSettingsScreen();
|
||||
void gotoQuickMsgScreen();
|
||||
void gotoMessagesScreen();
|
||||
void openContactDM(const ContactInfo& ci);
|
||||
void shareToMessage(const char* text); // open Messages pre-loaded to share `text`
|
||||
void quickShareMyLocation(); // Home Map Hold-Enter: one-shot position share
|
||||
@@ -229,6 +230,7 @@ public:
|
||||
void gotoToolsScreen();
|
||||
void gotoRingtoneEditor(int slot = 0);
|
||||
void gotoBotScreen();
|
||||
void gotoAdminScreen();
|
||||
void gotoNearbyScreen();
|
||||
void gotoDashboardConfig();
|
||||
void gotoAutoAdvertScreen();
|
||||
@@ -331,6 +333,7 @@ public:
|
||||
void onMsgAck(uint32_t ack_crc) override;
|
||||
void onChannelRelayed(uint32_t seq) override;
|
||||
void onRoomLoginResult(const uint8_t* pub_key, bool success, uint8_t permissions) override;
|
||||
void onAdminReply(const uint8_t* pub_key, const char* text) override;
|
||||
int getDMUnreadTotal() const;
|
||||
int getMsgCount() const { return _msgcount; }
|
||||
int getChannelUnreadCount() const;
|
||||
|
||||
Reference in New Issue
Block a user