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:
12
FEATURES.md
12
FEATURES.md
@@ -14,7 +14,7 @@ Hold Enter on the MESSAGE mode-select screen (DM / Channels / Rooms) opens a 1-i
|
||||
|
||||
Implementation:
|
||||
- New `UITask::clearAllDMUnread()` — `memset` over `_dm_unread_table`
|
||||
- `QuickMsgScreen::clearAllChannelUnread()` already existed
|
||||
- `MessagesScreen::clearAllChannelUnread()` already existed
|
||||
- `UITask::clearRoomUnread()` already existed
|
||||
- Title is a `static const char*` table (PopupMenu stores the title pointer verbatim — locals would dangle)
|
||||
- Zero schema impact, all counters live in RAM
|
||||
@@ -762,7 +762,7 @@ Unknown-secret packets no longer pollute the offline queue, UI history or trigge
|
||||
|
||||
### ✅ `addChannelMsg` guards against bogus index
|
||||
|
||||
[`QuickMsgScreen.h:414-433`](examples/companion_radio/ui-new/QuickMsgScreen.h#L414-L433)
|
||||
[`MessagesScreen.h:414-433`](examples/companion_radio/ui-new/MessagesScreen.h#L414-L433)
|
||||
|
||||
Defensive `if (ch_idx >= MAX_GROUP_CHANNELS) return;` at function entry — prevents ring-buffer pollution in case any future caller forgets the upstream guard. With C1 fixed this should never trigger, but the cost is zero.
|
||||
|
||||
@@ -787,7 +787,7 @@ Fixed: the save loop now skips unused slots (all-zero secret) instead of writing
|
||||
```cpp
|
||||
if (msgcount == 0) {
|
||||
memset(_dm_unread_table, 0, sizeof(_dm_unread_table));
|
||||
((QuickMsgScreen*)quick_msg)->clearAllChannelUnread();
|
||||
((MessagesScreen*)messages_screen)->clearAllChannelUnread();
|
||||
}
|
||||
```
|
||||
|
||||
@@ -795,7 +795,7 @@ When the companion app reads the last message from the offline queue, all on-dev
|
||||
|
||||
### ✅ Message buffers sized below the protocol maximum (clipped long messages)
|
||||
|
||||
[`QuickMsgScreen.h`](examples/companion_radio/ui-new/QuickMsgScreen.h), [`KeyboardWidget.h`](examples/companion_radio/ui-new/KeyboardWidget.h)
|
||||
[`MessagesScreen.h`](examples/companion_radio/ui-new/MessagesScreen.h), [`KeyboardWidget.h`](examples/companion_radio/ui-new/KeyboardWidget.h)
|
||||
|
||||
`ChHistEntry::text` was 140 B and `DmHistEntry::text` only 80 B, while the
|
||||
keyboard capped input at 139 B — all below MeshCore's `MAX_TEXT_LEN` (160 B).
|
||||
@@ -870,13 +870,13 @@ Re-checked: `BOT_SCRATCH` is 200 and `MAX_TEXT_LEN` is 160, so an incoming messa
|
||||
|
||||
### ✅ `strncpy("?", buf, sizeof(buf))` replaced with `strcpy`
|
||||
|
||||
[`QuickMsgScreen.h:785, 858`](examples/companion_radio/ui-new/QuickMsgScreen.h#L785)
|
||||
[`MessagesScreen.h:785, 858`](examples/companion_radio/ui-new/MessagesScreen.h#L785)
|
||||
|
||||
Two fallback "?" sender names now use a plain `strcpy` so we don't memset 21 unused bytes for a one-character string.
|
||||
|
||||
### ❌ Title truncation in MSG_PICK reply mode — not an overflow
|
||||
|
||||
[`QuickMsgScreen.h:1279-1287`](examples/companion_radio/ui-new/QuickMsgScreen.h#L1279)
|
||||
[`MessagesScreen.h:1279-1287`](examples/companion_radio/ui-new/MessagesScreen.h#L1279)
|
||||
|
||||
Re-checked: `rlen` is clamped to 20 before building `"RE:" + nick`, so the title is ≤23 chars and fits `title[24]` with no overflow. A nick longer than 20 chars is shown truncated, but that's an intentional fit-to-header limit (the OLED header only fits ~21 chars anyway), not a bug.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ All firmware files are published on the [releases page](https://github.com/Marek
|
||||
|
||||
## Feature highlights
|
||||
|
||||
- Extended language support with native Unicode rendering and input ([Lemon font](https://github.com/cmvnd/fonts)) alongside the original ASCII mode (Default font with transliteration)
|
||||
- Extended language support with native Unicode rendering and input ([Lemon font](https://github.com/cmvnd/fonts)) alongside the original ASCII mode (Default font with transliteration) — on-screen keyboard alphabets for Cyrillic, Greek, Polish, Czech, Slovak, German, French, Spanish, Portuguese and Nordic (Danish/Norwegian/Swedish), selectable in Settings › Keyboard › Alphabet
|
||||
|
||||
- Enabled sensor screens with support for onboard sensors (temperature, humidity, pressure, luminosity, CO₂) and GPS data
|
||||
|
||||
@@ -41,7 +41,7 @@ All firmware files are published on the [releases page](https://github.com/Marek
|
||||
- **GPS trail** — background route recording with an auto-fit map (waypoints + live position), summary stats, auto-pause on stops, and [GPX export](#solo-tools)
|
||||
- **Metric or imperial** — one global Units setting drives every distance and speed across the UI
|
||||
|
||||
- [Messages Screen](./docs/solo_features/message_screen/message_screen.md) — view and send messages, open message details, reply with quick messages or custom text, navigate to / save locations shared in a message, per-channel notification and melody overrides
|
||||
- [Messages Screen](./docs/solo_features/message_screen/message_screen.md) — view and send messages, open message details, reply with quick messages or custom text, navigate to / save locations shared in a message, per-channel notification and melody overrides, add/edit/delete channels on-device
|
||||
|
||||
- [Favourites Dial](./docs/solo_features/favourites_dial/favourites_dial.md) — pin up to six contacts for quick access from the home screen
|
||||
|
||||
@@ -51,7 +51,7 @@ All firmware files are published on the [releases page](https://github.com/Marek
|
||||
|
||||
- [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) — lock the device to prevent accidental keypresses, with a lock screen showing time and sensor data
|
||||
|
||||
- [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater
|
||||
- [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) — GPS trail & waypoints, compass, nearby nodes (with ping & navigate), ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater, remote admin
|
||||
|
||||
- **Battery saving (radio)** — two optional, independent toggles under Settings › Radio:
|
||||
- **Pwr save** — hardware duty-cycle receive (SX126x `SetRxDutyCycle`): the radio cycles RX↔sleep on its own and wakes on a preamble, cutting average RX current with only a little added receive latency
|
||||
@@ -99,7 +99,7 @@ Updating to a newer version usually does not require erasing flash unless the re
|
||||
| [Clock Screen](./docs/solo_features/clock_screen/clock_screen.md) | Clock page, date, configurable data fields, alarm / timer / stopwatch |
|
||||
| [Settings Screen](./docs/solo_features/settings_screen/settings_screen.md) | All settings sections with values and interactions |
|
||||
| [Screen Lock](./docs/solo_features/screen_lock/screen_lock.md) | Lock/unlock sequence, lock screen, auto-lock |
|
||||
| [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater |
|
||||
| [Tools Screen](./docs/solo_features/tools_screen/tools_screen.md) | GPS trail & waypoints, compass, navigation, nearby nodes, ringtone editor, auto-reply bot, auto-advert, live location sharing, locator, diagnostics, repeater, remote admin |
|
||||
| [Solo UI framework](./docs/design/solo_ui_framework.md) | **Developer guide** — the reusable building blocks (screens, lists, popups, mini-icons, geo/persistence helpers) and how to add a new feature |
|
||||
|
||||
### Upstream MeshCore
|
||||
|
||||
@@ -50,7 +50,7 @@ Posting to a **room server** requires a login handshake first, so the device can
|
||||
- **Log out** with **Hold Enter** on a room you're currently logged into → **Logout** (only offered once logged in). Forgets the saved password on the device, so the next time you open that room it prompts for one again instead of silently reusing the old one.
|
||||
- Passwords set from the **phone app** are saved on the device too, so it can post to that room standalone after a reboot.
|
||||
|
||||
> The on-screen keyboard's default (Latin) page is ASCII only. Typing accented or non-Latin characters — Polish/Czech/German/French diacritics, Cyrillic, or Greek — needs Settings › Keyboard › Alphabet set to the matching alphabet first; the keyboard's **#@/abc** key then cycles Latin → that alphabet → Symbols → Latin. A password containing characters outside whatever's currently enabled can still be set from the phone app — the device stores and replays it byte-for-byte.
|
||||
> The on-screen keyboard's default (Latin) page is ASCII only. Typing accented or non-Latin characters — Polish, Czech, Slovak, German, French, Spanish, Portuguese or Nordic diacritics, Cyrillic, or Greek — needs Settings › Keyboard › Alphabet set to the matching language first; the keyboard's **#@/abc** key then cycles Latin → that alphabet → Symbols → Latin. A password containing characters outside whatever's currently enabled can still be set from the phone app — the device stores and replays it byte-for-byte.
|
||||
|
||||
---
|
||||
|
||||
@@ -129,6 +129,24 @@ In the **Rooms** list the context menu instead offers:
|
||||
| Notif: default / OFF / ON | Per-channel notification override — **LEFT/RIGHT** to cycle |
|
||||
| Melody: global / M1 / M2 | Per-channel melody override — **LEFT/RIGHT** to cycle |
|
||||
| Fav: yes / no | Add or remove this channel from favourites — **LEFT/RIGHT** to toggle |
|
||||
| Edit | Opens the Add/Edit form below, pre-filled with the channel's name |
|
||||
| Delete | Removes the channel immediately (no confirm prompt) |
|
||||
|
||||
---
|
||||
|
||||
### Adding / editing a channel
|
||||
|
||||
Joining a new community channel, or creating one to share with others, no longer needs the phone app. The **Channels** list ends with a **"+ Add channel"** row — press **Enter** on it to open the form, or use **Edit** from the context menu above to change an existing channel's name or secret.
|
||||
|
||||
| Field | Notes |
|
||||
| ------ | ---------------------------------------------------------------------------------------------- |
|
||||
| Name | Up to 31 characters |
|
||||
| Secret | **LEFT/RIGHT** toggles between two entry modes; **Enter** opens the keyboard for whichever is selected |
|
||||
|
||||
- **Passphrase** (default) — type any text; the device hashes it down to the channel's 16-byte secret. Easiest to agree on verbally, the same idea as a room password — two people who type the same passphrase end up on the same channel.
|
||||
- **Hex key** — type the exact 32-hex-character secret (the format used by channel QR codes, see [QR Codes](../../qr_codes.md)), for joining a channel whose precise secret you were given rather than agreeing on a new passphrase.
|
||||
|
||||
Select **[Save]** to commit. The secret can't be redisplayed once saved (only the derived key is kept) — editing it later means typing a new passphrase or hex key, the same as re-logging into a room with a new password.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ The **repeater** mode and its flood filters live on their own screen — see **T
|
||||
| Setting | Options | Notes |
|
||||
| -------- | ---------- | -------------------------------------------------------------------------------------------------- |
|
||||
| Layout | ABC / T9 | On-screen keyboard style. **ABC**: an a-b-c…z grid, one key per letter (the original layout). **T9**: phone-keypad multi-tap — each key is labelled with its **digit** and a letter group (e.g. `2abc`); repeated **Enter** presses cycle through the letters and then the digit itself. Applies to whichever alphabet page is active (see Alphabet below), not just Latin. |
|
||||
| Alphabet | Latin / Cyrillic / Greek / Ext.Latin | Which extra (non-Latin) alphabet, if any, joins the keyboard's page cycle. **Latin** (default): only the Latin letters and Symbols pages, as before. Any other choice adds that alphabet's own page to the same **#@/abc** key's cycle (Latin → alphabet → Symbols → Latin) — no separate key to switch scripts. **Ext.Latin** is a curated set of Polish/Czech/Slovak/German/French/Spanish/Nordic diacritics (`ą ć ę ł ń ó ś ź ż`, `č š ž`, `ä ö ü ß`, `é è ê ë ñ`, etc.), not full Unicode coverage. **Greek** covers the 24-letter alphabet plus final sigma (`ς`) but not the tonos stress accents used in proper Modern Greek spelling. Typing in the chosen alphabet needs the **Lemon** font to actually display (Settings › Display › Font) — the keyboard forces it on temporarily while that page is open even if Font is set to Default, but received messages and everything else still follow your own Font choice. |
|
||||
| Alphabet | Latin / Cyrillic / Greek / Polish / Czech / Slovak / German / French / Spanish / Portuguese / Nordic | Which extra (non-Latin) alphabet, if any, joins the keyboard's page cycle. **Latin** (default): only the Latin letters and Symbols pages, as before. Any other choice adds that alphabet's own page to the same **#@/abc** key's cycle (Latin → alphabet → Symbols → Latin) — no separate key to switch scripts. Each Latin-diacritic language (Polish, Czech, Slovak, German, French, Spanish, Portuguese, Nordic) is its own separate page with that language's full, correct set of non-ASCII letters — e.g. Czech gets `á č ď é ě í ň ó ř š ť ú ů ý ž`, German just `ä ö ü ß`. Nordic covers `å ä æ ö ø`, shared across Danish/Norwegian/Swedish since their alphabets only differ in which of those five each one uses. **Greek** covers the 24-letter alphabet plus final sigma (`ς`) but not the tonos stress accents used in proper Modern Greek spelling. Typing in the chosen alphabet needs the **Lemon** font to actually display (Settings › Display › Font) — the keyboard forces it on temporarily while that page is open even if Font is set to Default, but received messages and everything else still follow your own Font choice. |
|
||||
|
||||
Applies to every on-screen text field (messages, waypoint labels, room passwords, preset names). Earlier releases labelled the grid *QWERTY*; the layout has always been alphabetical, so it is now named **ABC**.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
| :-----------------------: | :-----------------------: |
|
||||
|  |  |
|
||||
|
||||
The Tools screen is a hub for GPS trail recording, nearby node browsing, ringtone editing, auto-reply bot, auto-advert, live location sharing, locator, compass, clock tools (alarm / timer / stopwatch), device diagnostics, and repeater mode. Tools are grouped into collapsible **Location** / **Comms** / **System** sections — the same fold-in-place model as Settings; Tools always opens folded back to the section list. Navigate with **UP/DOWN**, press **Enter** on a section header to expand or collapse it, or on a tool to open it.
|
||||
The Tools screen is a hub for GPS trail recording, nearby node browsing, ringtone editing, auto-reply bot, auto-advert, live location sharing, locator, compass, clock tools (alarm / timer / stopwatch), device diagnostics, repeater mode, and remote admin. Tools are grouped into collapsible **Location** / **Comms** / **System** sections — the same fold-in-place model as Settings; Tools always opens folded back to the section list. Navigate with **UP/DOWN**, press **Enter** on a section header to expand or collapse it, or on a tool to open it.
|
||||
|
||||
---
|
||||
|
||||
@@ -509,3 +509,34 @@ The five flood filters are **opt-in** (default OFF, so a plain repeater is unaff
|
||||
While the repeater is on, a **»** indicator appears in the status bar (same blink convention as the auto-advert and trail markers) so you can tell it's relaying at a glance. Two radio settings are also overridden while relaying and restored afterwards: **Settings › Radio › Pwr save** is forced off (a repeater must listen continuously) and **Auto pwr** is forced off (a repeater holds full TX power for consistent relay reach). Both show `--` in Settings while the repeater is on.
|
||||
|
||||
Live forwarding stats — **Forwarded**, **Pool free**, **Queue** — are shown on **Tools › Diagnostics** (this screen is config-only).
|
||||
|
||||
---
|
||||
|
||||
## Admin
|
||||
|
||||
<!-- screenshot pending: Admin — target picker, command entry, reply view -->
|
||||
|
||||
Send CLI commands to a **repeater or 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 [CLI Commands](../../cli_commands.md) for the full command grammar.
|
||||
|
||||
1. **Select a node** — the list shows every repeater/room-server contact. Press **Enter** to pick one.
|
||||
2. **Log in** — type the node's **admin password** (this is the same login handshake Messages uses for room servers; a repeater's admin password is set with the `password` CLI command). If a password was already saved for this node from an earlier successful login, it retries silently instead of prompting. Only a login that comes back with **admin**-level permission unlocks the next step — anything less shows "Not admin on this node" and returns to the list.
|
||||
3. **Pick a category and a field** — a tab carousel (**LEFT/RIGHT** to switch category, **UP/DOWN** to move within it, same as Auto-Reply Bot's tabs), so common settings don't need the CLI grammar memorised:
|
||||
|
||||
| Tab | Rows |
|
||||
| --- | ---- |
|
||||
| **System** | Name, Owner info, Admin password |
|
||||
| **Radio** | Radio (freq, bandwidth, spreading factor, coding rate), TX power |
|
||||
| **Routing** | Repeat, Advert interval, Flood advert interval, Max hops |
|
||||
| **Actions** | Reboot, Send advert, Send zero-hop advert, Sync clock, **Custom command...** |
|
||||
|
||||
**Enter** on a row does one of three things, depending on the field:
|
||||
- Most **System/Radio/Routing** rows first **fetch** the node's current value, then open the keyboard **pre-filled** with it to edit — submitting sends the change. If the fetch fails or times out, the keyboard still opens (blank), so the value can be set blind.
|
||||
- **Admin password** has no fetch (there's no way to read a password back) — it opens straight to a blank keyboard.
|
||||
- **Actions** (Reboot, Send advert, …) send immediately, no editing step.
|
||||
- **Custom command...** (last row of Actions) opens the same free-text entry for anything not covered above — up to 160 characters, see the linked reference for the full grammar.
|
||||
4. **Read the reply** — the text reply opens in a scrollable view (**UP/DOWN** to scroll, **Cancel/Enter** to go back to the category tabs).
|
||||
|
||||
> [!WARNING]
|
||||
> This screen can run **destructive** commands on the *remote* node — `reboot`, `erase`, a new admin password, and others. That's the same capability the phone app's repeater-admin feature already exposes, not a new risk, but double-check the value and the target before sending.
|
||||
|
||||
**Passwords are remembered across reboots**, the same self-healing behaviour as room logins in Messages: after a successful admin login the password is saved on the device, so picking that node again — even after a power cycle — logs back in silently. If a saved password stops working (e.g. it was changed on the node), the failed login forgets it, so the next pick prompts for a new one. A correct password that just lacks admin permission is left alone — retyping the same one wouldn't change the outcome. Some commands are marked **Serial Only** in the CLI reference — those reject a remote CLI request and only work over that node's own USB serial connection.
|
||||
|
||||
@@ -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; // à-þ -> À-Þ
|
||||
@@ -257,7 +348,14 @@ struct KeyboardWidget {
|
||||
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_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 "?";
|
||||
}
|
||||
}
|
||||
@@ -273,7 +371,14 @@ struct KeyboardWidget {
|
||||
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_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 "?";
|
||||
}
|
||||
}
|
||||
@@ -289,7 +394,14 @@ struct KeyboardWidget {
|
||||
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_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