diff --git a/docs/development/l2-hardware-checklist.md b/docs/development/l2-hardware-checklist.md index 62c3dfc4..340d8ac0 100644 --- a/docs/development/l2-hardware-checklist.md +++ b/docs/development/l2-hardware-checklist.md @@ -66,6 +66,16 @@ Confirmed: green dot inside the ring, waypoints much better, download works, set - [ ] Room server login from the phone app / L1 still works while this exists (only admin logins go to the new session) - [ ] L1 (ui-new): Tools > Admin works as before (login, saved password, typed values, confirm reboot); it now also skips the login for the node you just used +## New: Quick messages, placeholders, advert, Bluetooth (2026-09-26) +- [ ] Chat "+" (left of the text field): Insert {loc} / {time} / {batt} (+ sensors) into the message; they are filled in when sent (a reply's "@[name] " stays as typed) +- [ ] Chat "+" > a quick message sends it at once (the popup shows what will go out, filled in); "Edit quick messages" opens the list +- [ ] Settings > Messages & contacts > Quick messages: 10 slots, tap to edit (field, placeholder chips, keyboard); empty clears; "OK" is there on a fresh device; the same slots as L1's Settings > Messages +- [ ] Settings > NODE > Send advert: Nearby only (zero-hop) / Everyone (flood through repeaters); another node sees you +- [ ] Settings > CONNECTIVITY > Bluetooth: off / on; the row shows the pairing PIN while waiting, "the app is connected" when paired; USB keeps working with Bluetooth off; after a reboot Bluetooth is on again (as on L1) +- [ ] Messages: "Read all" in the header while anything is unread +- [ ] Nearby > a node with a position: flag button saves it as a waypoint; pin button puts a contact on the favourites dial; with five or more buttons they show icons only; delete asks with "trash?" and a toast +- [ ] L1: quick messages, placeholders and the sensor placeholder list work as before (now shared through ui-core/MessageText.h) + ## New: Sound (2026-09-26) The speaker plays through the ES8311 codec (I2S MCLK 10 / BCK 11 / WS 12 / DOUT 16, amp on expander P12). Pins come from Seeed's Meshtastic port; nothing was heard on the device yet. - [ ] A short startup chime after boot (sound On); a goodbye sound on Power off diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index 4714c564..3220c7f9 100644 --- a/docs/development/ui-core.md +++ b/docs/development/ui-core.md @@ -110,7 +110,7 @@ call the same ones, so e.g. "marking a channel read" has one implementation. ### 5. Declarative settings schema -`ui-core/SettingsSchema.h` holds four pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, lock screen, battery display, battery shutdown, GPS power saving, time zone, 12-hour clock, clock seconds) messages & contacts (DM resends, contact expiry, favourites first) and sound (volume, the sound for direct messages / channels / adverts, advert sound scope). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h` (`pinContact` / `pinChannel` move an entry that is already on another slot; the slot's name is `contactctl::favName`). The schema's display page also carries the lock screen (`auto_lock`) and the 12-hour clock, which ui-lvgl implements (a slide-to-unlock card after the screen turns off). Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh. Room servers: `ui-core/RoomSessions.h` (`UiCore::rooms`) keeps who is logged in this session, the one login in flight (with a timeout) and the saved passwords (saved on success, forgotten on failure); `open(contact)` says whether to go straight in, wait for a background login or ask for a password, and outcomes come out through `take()`. Per-contact helpers are `ui-core/ContactControl.h` (`contactctl::`: DM notification / melody overrides, favourite flag, the `@[name] ` reply prefix, splitting a room post into author and text, naming a message's path hops). ui-new's Messages and ui-lvgl's Chats / thread use both. Diagnostics rows are `ui-core/Diagnostics.h` (`diag::`: live counters, system card, font card, counter reset), shown by ui-new's Tools > Diagnostics and ui-lvgl's Settings > Diagnostics. Auto-advert is a schema row (`advert_auto_interval_sec`, sent by MyMesh); ui-new keeps its Tools > Auto-Advert screen over the same pref. The compass (ui-lvgl Home > Compass, ui-new Tools > Compass) reads `UiCore::course`. Saving / deleting the user's radio presets is `radioctl::saveUserPreset` / `deleteUserPreset` (ui-new's preset picker and ui-lvgl's Radio > My presets); the scope list is edited through MyMesh (`addScope` / `renameScope` / `removeScope` / `setDefaultScope`), which ui-lvgl's Radio > Scopes and ui-new's Settings > Radio > Scope call directly. The battery percentage (LiPo curve, empty at the shutdown voltage) and the display mode are `ui-core/Battery.h`, used by both status bars; the schema carries Battery display and Clock seconds. The companion's own repeater mode is `ui-core/RepeaterControl.h` (`rptctl::`: on / off with its radio, RX power-save and APC side effects, Current / Custom network with the profile seeded from the chat params, the profile's preset, the filters' ranges and labels, the extra scopes mask); ui-new's Tools > Repeater and ui-lvgl's Settings > Repeater use it, MyMesh does the relaying. Sound is `ui-core/SoundControl.h` (`soundctl::`: the On / Off / Auto mode over `buzzer_quiet` + `buzzer_auto`, the volume with its preview, the built-in sounds, the two user melodies as an editable note list) and `ui-core/SoundNotifier.h` (which sound a message / advert plays: global choice, per-contact / per-channel alert and melody overrides); the schema's Sound page holds the volume and the PLAYS FOR choices, the channel melody override is `chanctl::melody`. Both frontends drive a `genericBuzzer`: a PWM piezo on L1, the ES8311 codec on the L2 (`BUZZER_I2S`: an audio task synthesises the notes and counts samples, the board powers the amp through `buzzerAmpPower()`). +`ui-core/SettingsSchema.h` holds four pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, lock screen, battery display, battery shutdown, GPS power saving, time zone, 12-hour clock, clock seconds) messages & contacts (DM resends, contact expiry, favourites first) and sound (volume, the sound for direct messages / channels / adverts, advert sound scope). An entry is a one-byte index by default, or a `values` table over a 1/2/4-byte field (seconds, millivolts, an hour offset, `{1, 0}` for a field stored inverted); side effects run through `UiCore` (`applyGpsInterval()`, `host()->applyDisplayPrefs()`). ui-lvgl renders each page; ui-new still uses its own screens. Radio settings are not schema rows (preset list, typed frequency, radio side effects): `ui-core/RadioControl.h` applies them (`applyParams`, `applyTxPower`, `applyApc`, presets = built-ins + user slots) for both ui-new's Settings > Radio and ui-lvgl's Radio screen. Channel management is `ui-core/ChannelControl.h` (`chanctl::`): add Public / Hashtag / Private (passphrase or 32-hex key), rename, delete, notifications / favourite / scope, and `onRemoved()` -- the cleanup of every channel-keyed pref, run by `UiCore::onChannelRemoved` whoever deleted the channel (this device or the app); contacts get the same through `UiCore::cleanupContactPrefs`. Favourites dial slots: `ui-core/Favourites.h` (`pinContact` / `pinChannel` move an entry that is already on another slot; the slot's name is `contactctl::favName`). The schema's display page also carries the lock screen (`auto_lock`) and the 12-hour clock, which ui-lvgl implements (a slide-to-unlock card after the screen turns off). Remote admin is `ui-core/AdminSession.h` (`UiCore::admin`): login with the saved password (forgotten on a wrong password or a silent timeout), CLI round trips with timeouts, the field table (`admin::`), typed values; results come out one at a time through `take()`. `UiCore` offers login answers and CLI replies to it first; only room logins it wasn't waiting for reach the frontend. ui-new's Tools > Admin and ui-lvgl's Admin screen are views over it. The bot's settings are a table too, `ui-core/BotConfig.h` (`botcfg::`: tabs, rows, the NodePrefs flag / text / hour behind each, reply placeholders); ui-new's Tools > Bot and ui-lvgl's Bot screen render it, the bot itself runs in MyMesh. Room servers: `ui-core/RoomSessions.h` (`UiCore::rooms`) keeps who is logged in this session, the one login in flight (with a timeout) and the saved passwords (saved on success, forgotten on failure); `open(contact)` says whether to go straight in, wait for a background login or ask for a password, and outcomes come out through `take()`. Per-contact helpers are `ui-core/ContactControl.h` (`contactctl::`: DM notification / melody overrides, favourite flag, the `@[name] ` reply prefix, splitting a room post into author and text, naming a message's path hops). ui-new's Messages and ui-lvgl's Chats / thread use both. Diagnostics rows are `ui-core/Diagnostics.h` (`diag::`: live counters, system card, font card, counter reset), shown by ui-new's Tools > Diagnostics and ui-lvgl's Settings > Diagnostics. Auto-advert is a schema row (`advert_auto_interval_sec`, sent by MyMesh); ui-new keeps its Tools > Auto-Advert screen over the same pref. The compass (ui-lvgl Home > Compass, ui-new Tools > Compass) reads `UiCore::course`. Saving / deleting the user's radio presets is `radioctl::saveUserPreset` / `deleteUserPreset` (ui-new's preset picker and ui-lvgl's Radio > My presets); the scope list is edited through MyMesh (`addScope` / `renameScope` / `removeScope` / `setDefaultScope`), which ui-lvgl's Radio > Scopes and ui-new's Settings > Radio > Scope call directly. The battery percentage (LiPo curve, empty at the shutdown voltage) and the display mode are `ui-core/Battery.h`, used by both status bars; the schema carries Battery display and Clock seconds. The companion's own repeater mode is `ui-core/RepeaterControl.h` (`rptctl::`: on / off with its radio, RX power-save and APC side effects, Current / Custom network with the profile seeded from the chat params, the profile's preset, the filters' ranges and labels, the extra scopes mask); ui-new's Tools > Repeater and ui-lvgl's Settings > Repeater use it, MyMesh does the relaying. Sound is `ui-core/SoundControl.h` (`soundctl::`: the On / Off / Auto mode over `buzzer_quiet` + `buzzer_auto`, the volume with its preview, the built-in sounds, the two user melodies as an editable note list) and `ui-core/SoundNotifier.h` (which sound a message / advert plays: global choice, per-contact / per-channel alert and melody overrides); the schema's Sound page holds the volume and the PLAYS FOR choices, the channel melody override is `chanctl::melody`. Both frontends drive a `genericBuzzer`: a PWM piezo on L1, the ES8311 codec on the L2 (`BUZZER_I2S`: an audio task synthesises the notes and counts samples, the board powers the amp through `buzzerAmpPower()`). Outgoing text is `ui-core/MessageText.h` (`msgtext::`: the ten quick messages, the placeholders on offer -- `{loc}`, `{time}`, `{batt}`, live sensor readings -- and expanding them at send time, keeping a reply's `@[name] ` prefix as typed); ui-new's Messages / keyboard and ui-lvgl's compose "+" use it. `UiCore::markAllRead()` clears every unread counter; `MyMesh::advertFlood()` (formerly sim-only) sends the self-advert through repeaters, `advert()` zero-hop. Replaces the per-item code in `SettingsScreen`: diff --git a/examples/companion_radio/MyMesh.cpp b/examples/companion_radio/MyMesh.cpp index 4380d677..fd24c413 100644 --- a/examples/companion_radio/MyMesh.cpp +++ b/examples/companion_radio/MyMesh.cpp @@ -3462,12 +3462,10 @@ bool MyMesh::advert() { } } -#ifdef SIM_PLATFORM bool MyMesh::advertFlood() { // Mirrors the CMD_SEND_SELF_ADVERT handler's flood=1 branch above // (createSelfAdvert() + sendFloodScoped() with the default transport - // scope key) -- real protocol logic, just reached from a test-only entry - // point instead of a parsed serial command frame. + // scope key), for the on-device UI and the sim's test harness. mesh::Packet* pkt; if (_prefs.advert_loc_policy == ADVERT_LOC_NONE) { pkt = createSelfAdvert(_prefs.node_name); @@ -3483,7 +3481,6 @@ bool MyMesh::advertFlood() { return false; } } -#endif // To check if there is pending work bool MyMesh::hasPendingWork() const { diff --git a/examples/companion_radio/MyMesh.h b/examples/companion_radio/MyMesh.h index 5182df7d..0e960273 100644 --- a/examples/companion_radio/MyMesh.h +++ b/examples/companion_radio/MyMesh.h @@ -204,18 +204,8 @@ public: void loop(); void handleCmdFrame(size_t len); - bool advert(); -#ifdef SIM_PLATFORM - // Phase 3 sim-only test hook: same as advert() but FLOOD-routed (like the - // real phone-app CMD_SEND_SELF_ADVERT command's flood=1 branch) instead of - // zero-hop, so a JS test harness can make an instance's identity actually - // propagate through an intermediate repeater instance -- advert() alone - // (zero-hop) never leaves the immediate ether link. Not reachable from any - // real hardware build (no phone app exists in the sim to send the real - // CMD_SEND_SELF_ADVERT command over), so this is additive/dead code - // everywhere else, not a behavior change. - bool advertFlood(); -#endif + bool advert(); // zero-hop: only nodes in direct range hear it + bool advertFlood(); // flooded (default scope), as the app's "Advert" with flood void sendNodeDiscoverReq(); void enterCLIRescue(); diff --git a/examples/companion_radio/ui-core/MessageText.h b/examples/companion_radio/ui-core/MessageText.h new file mode 100644 index 00000000..e33ffa77 --- /dev/null +++ b/examples/companion_radio/ui-core/MessageText.h @@ -0,0 +1,108 @@ +#pragma once +// Outgoing message text, shared by ui-new's Messages and ui-lvgl's thread: +// the placeholders a message can carry ({loc}, {time}, {batt}, live sensor +// readings), expanding them with this device's position / clock / battery / +// sensors when it's sent, and the ten quick messages (NodePrefs::custom_msgs), +// sent with one tap and expanded the same way. +// +// Include after target.h (sensors, board, rtc_clock). + +#include "../MsgExpand.h" + +namespace msgtext { + +// ── Quick messages ──────────────────────────────────────────────────────────── +static const int QUICK_COUNT = 10; +static const int QUICK_LEN = (int)sizeof(((NodePrefs*)0)->custom_msgs[0]); // incl. the terminator + +static const char* quick(const NodePrefs* p, int i) { + return p && i >= 0 && i < QUICK_COUNT ? p->custom_msgs[i] : ""; +} +// "" clears the slot. The caller saves the prefs. +static void setQuick(NodePrefs* p, int i, const char* text) { + if (p && i >= 0 && i < QUICK_COUNT) snprintf(p->custom_msgs[i], QUICK_LEN, "%s", text ? text : ""); +} +static int quickUsed(const NodePrefs* p) { + int n = 0; + for (int i = 0; i < QUICK_COUNT; i++) if (quick(p, i)[0]) n++; + return n; +} +// A fresh device starts with "OK" in the first slot. +static void seedQuick(NodePrefs* p) { + if (p && !p->custom_msgs[0][0]) setQuick(p, 0, "OK"); +} + +// ── Placeholders ────────────────────────────────────────────────────────────── + +// The sensor readings this device has right now, as placeholders ({temp}, +// {hum}, ...; {batt} only from a voltage sensor). +static void sensorPlaceholders(SensorManager* sm, void (*add)(const char* ph, void* ctx), void* ctx) { + if (!sm) return; + CayenneLPP lpp(100); + sm->querySensors(0xFF, lpp); + uint8_t present[16]; + int pc = 0; + LPPReader r(lpp.getBuffer(), lpp.getSize()); + uint8_t ch, type; + while (r.readHeader(ch, type)) { + bool seen = false; + for (int i = 0; i < pc; i++) if (present[i] == type) { seen = true; break; } + if (!seen && pc < 16) present[pc++] = type; + r.skipData(type); + } + static const struct { uint8_t t; const char* ph; } MAP[] = { + { LPP_TEMPERATURE, "{temp}" }, { LPP_RELATIVE_HUMIDITY, "{hum}" }, { LPP_BAROMETRIC_PRESSURE, "{pres}" }, + { LPP_VOLTAGE, "{batt}" }, { LPP_ALTITUDE, "{alt}" }, { LPP_LUMINOSITY, "{lux}" }, + { LPP_DISTANCE, "{dist}" }, { LPP_CONCENTRATION, "{co2}" }, + }; + for (const auto& m : MAP) + for (int i = 0; i < pc; i++) + if (present[i] == m.t) { add(m.ph, ctx); break; } +} + +// Everything a message can carry here: {loc}, {time}, {batt} (the board's +// voltage), then the sensors'. +static void placeholders(SensorManager* sm, void (*add)(const char* ph, void* ctx), void* ctx) { + add("{loc}", ctx); + add("{time}", ctx); + add("{batt}", ctx); + struct Skip { void (*add)(const char*, void*); void* ctx; } skip = { add, ctx }; + sensorPlaceholders(sm, [](const char* ph, void* c) { + Skip* s = (Skip*)c; + if (strcmp(ph, "{batt}") != 0) s->add(ph, s->ctx); // already there + }, &skip); +} + +// ── Expanding ───────────────────────────────────────────────────────────────── + +// Placeholders in `tmpl` replaced with this device's values. +static void expand(const char* tmpl, char* out, int n, const NodePrefs* p) { + double lat = 0, lon = 0; + bool gps_valid = false; +#if ENV_INCLUDE_GPS == 1 + LocationProvider* loc = sensors.getLocationProvider(); + if (loc && loc->isValid()) { + lat = loc->getLatitude() / 1000000.0; + lon = loc->getLongitude() / 1000000.0; + gps_valid = true; + } +#endif + float batt = (float)board.getBattMilliVolts() / 1000.0f; + ::expandMsg(tmpl, out, n, lat, lon, gps_valid, rtc_clock.getCurrentTime(), + p ? p->tz_offset_hours : 0, &sensors, batt); +} + +// A typed message: a reply's leading "@[nick] " stays as it is (a nick may +// hold braces), the rest is expanded. +static void expandOutgoing(const char* text, char* out, int n, const NodePrefs* p) { + int pre = 0; + if (text[0] == '@' && text[1] == '[') { + const char* e = strstr(text, "] "); + if (e) pre = (int)(e + 2 - text); + } + if (pre >= n) pre = n - 1; + memcpy(out, text, pre); + expand(text + pre, out + pre, n - pre, p); +} + +} // namespace msgtext diff --git a/examples/companion_radio/ui-core/UiCore.h b/examples/companion_radio/ui-core/UiCore.h index 8839d6db..451bf590 100644 --- a/examples/companion_radio/ui-core/UiCore.h +++ b/examples/companion_radio/ui-core/UiCore.h @@ -87,6 +87,8 @@ public: bool anyDMUnreadOverflow() const { return dm_unread.anyOverflow(); } void clearDMUnread(const uint8_t* pub_key) { dm_unread.clear(pub_key); } void clearAllDMUnread() { dm_unread.clearAll(); } + // Every conversation read: direct, channels, rooms. + void markAllRead() { _room_unread = 0; dm_unread.clearAll(); history.clearAllChannelUnread(); } void reconcileDMUnread() { dm_unread.reconcile(history); } // ── Actions ─────────────────────────────────────────────────────────────── @@ -153,11 +155,7 @@ public: void onQueueSizeChanged(int msgcount) override { _queue_len = msgcount; - if (msgcount == 0) { // the app drained the queue: nothing is unread any more - _room_unread = 0; - dm_unread.clearAll(); - history.clearAllChannelUnread(); - } + if (msgcount == 0) markAllRead(); // the app drained the queue: nothing is unread any more } void onACKRecv(uint32_t ack_crc) override { history.markDmDelivered(ack_crc); } diff --git a/examples/companion_radio/ui-lvgl/QuickScreen.h b/examples/companion_radio/ui-lvgl/QuickScreen.h new file mode 100644 index 00000000..21202d1b --- /dev/null +++ b/examples/companion_radio/ui-lvgl/QuickScreen.h @@ -0,0 +1,237 @@ +// Quick messages and placeholders (the compose bar's "+", Settings > Messages +// & contacts > Quick messages), Settings > Send advert and > Bluetooth, and +// Messages' "Read all". The text side is ui-core/MessageText.h. +// Included by UITask.cpp (single translation unit). + +namespace qview { + +static const int PH_MAX = 12; +static const char* s_ph[PH_MAX]; // placeholders on offer (static strings) +static int s_ph_n = 0; +static int s_edit = -1; // quick message being edited + +static void collect() { + s_ph_n = 0; + msgtext::placeholders(&sensors, [](const char* ph, void*) { if (s_ph_n < PH_MAX) s_ph[s_ph_n++] = ph; }, nullptr); +} + +// The placeholders as small buttons; `cb` gets the index as user data. +static lv_obj_t* chips(lv_obj_t* parent, lv_event_cb_t cb, bool wrap) { + lv_obj_t* row = lv_obj_create(parent); + styleSurface(row, theme::BG); + lv_obj_set_size(row, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(row, wrap ? LV_FLEX_FLOW_ROW_WRAP : LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_column(row, 4, 0); + lv_obj_set_style_pad_row(row, 4, 0); + if (wrap) lv_obj_remove_flag(row, LV_OBJ_FLAG_SCROLLABLE); + else { lv_obj_set_scroll_dir(row, LV_DIR_HOR); lv_obj_set_scrollbar_mode(row, LV_SCROLLBAR_MODE_OFF); } + for (int i = 0; i < s_ph_n; i++) { + lv_obj_t* b = lv_button_create(row); + lv_obj_set_size(b, LV_SIZE_CONTENT, 28); + lv_obj_set_style_pad_hor(b, 8, 0); + lv_obj_set_style_pad_ver(b, 0, 0); + lv_obj_set_style_radius(b, 8, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), 0); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, (void*)(uintptr_t)i); + lv_obj_center(label(b, s_ph[i], THEME_FONT_SMALL, theme::TEXT)); + } + return row; +} + +static void onQuickSend(lv_event_t* e) { s_ui->quickSend((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onQuickInsert(lv_event_t* e) { s_ui->quickInsert((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onQuickSlot(lv_event_t* e) { s_ui->quickEdit((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onQuickEditPh(lv_event_t* e) { s_ui->quickEditInsert((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onQuickEditKb(lv_event_t* e) { s_ui->quickEditDone(lv_event_get_code(e) == LV_EVENT_READY); } +static void onQuickList(lv_event_t* e) { (void)e; s_ui->showQuickMsgs(); } +static void onAdvertGo(lv_event_t* e) { s_ui->advertSend((uintptr_t)lv_event_get_user_data(e) != 0); } +static void onBtSwitch(lv_event_t* e) { + s_ui->setBluetooth(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED)); +} + +} // namespace qview + +static void onAdvertRow(lv_event_t* e) { (void)e; s_ui->advertPopup(); } +static void onOpenQuickMsgs(lv_event_t* e) { (void)e; s_ui->showQuickMsgs(); } + +// ── The compose bar's "+" ───────────────────────────────────────────────────── + +void UITask::quickPopup() { + using namespace qview; + if (!_compose_ta) return; + collect(); + lv_obj_t* panel = navPopupPanel("Quick", true); + lv_obj_add_flag(panel, LV_OBJ_FLAG_SCROLLABLE); + label(panel, "Insert, filled in when sent:", THEME_FONT_SMALL, theme::TEXT_MUTED); + chips(panel, onQuickInsert, true); + label(panel, "Send now:", THEME_FONT_SMALL, theme::TEXT_MUTED); + int shown = 0; + for (int i = 0; i < msgtext::QUICK_COUNT; i++) { + const char* t = msgtext::quick(_prefs, i); + if (!t[0]) continue; + char sent[MSG_TEXT_BUF]; + msgtext::expand(t, sent, sizeof(sent), _prefs); + listRow(panel, t, strcmp(sent, t) ? sent : NULL, onQuickSend, (void*)(uintptr_t)i); // what goes out + shown++; + } + if (!shown) label(panel, "No quick messages yet.", THEME_FONT_SMALL, theme::TEXT_MUTED); + listRow(panel, LV_SYMBOL_EDIT " Edit quick messages", NULL, onQuickList, NULL); +} + +void UITask::quickSend(int slot) { + const char* t = msgtext::quick(_prefs, slot); + if (!t[0]) return; + char text[MSG_TEXT_BUF]; + msgtext::expand(t, text, sizeof(text), _prefs); + navClosePopup(); + if (sendThreadText(text)) refreshThread(); +} + +void UITask::quickInsert(int ph) { + using namespace qview; + if (ph < 0 || ph >= s_ph_n || !_compose_ta) return; + navClosePopup(); + lv_textarea_add_text(_compose_ta, s_ph[ph]); // the byte cap still applies (onComposeInsert) + setKeyboardVisible(true); +} + +// ── Settings > Messages & contacts > Quick messages ─────────────────────────── + +void UITask::showQuickMsgs() { + navClosePopup(); + _screen = SCR_QUICK; + buildQuickMsgs(); +} + +void UITask::buildQuickMsgs() { + using namespace qview; + lv_obj_t* body = newScreen("Quick messages", true); + lv_obj_t* t = label(body, "Sent with one tap from a chat's \"+\". {loc}, {time}, {batt} are filled in when sent.", + THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); + lv_obj_set_width(t, LV_PCT(100)); + for (int i = 0; i < msgtext::QUICK_COUNT; i++) { + const char* q = msgtext::quick(_prefs, i); + char title[msgtext::QUICK_LEN + 8]; + snprintf(title, sizeof(title), "%d %s", i + 1, q[0] ? q : "(empty)"); + lv_obj_t* row = listRow(body, title, NULL, onQuickSlot, (void*)(uintptr_t)i); + if (!q[0]) lv_obj_set_style_text_color(lv_obj_get_child(row, 0), lv_color_hex(theme::TEXT_MUTED), 0); + } +} + +// A field and the placeholders over the keyboard (a titled popup doesn't fit +// above 124 px of keys); the field's placeholder says which slot it is. +void UITask::quickEdit(int slot) { + using namespace qview; + if (!_prefs || slot < 0 || slot >= msgtext::QUICK_COUNT) return; + collect(); + s_edit = slot; + navClosePopup(); + _nav_overlay = lv_obj_create(lv_screen_active()); + lv_obj_remove_style_all(_nav_overlay); + lv_obj_set_size(_nav_overlay, LV_PCT(100), LV_PCT(100)); + lv_obj_set_style_bg_color(_nav_overlay, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(_nav_overlay, LV_OPA_80, 0); + lv_obj_add_flag(_nav_overlay, LV_OBJ_FLAG_CLICKABLE); + lv_obj_remove_flag(_nav_overlay, LV_OBJ_FLAG_SCROLLABLE); + int w = lv_display_get_horizontal_resolution(NULL) - 16; + + char ph[40]; + snprintf(ph, sizeof(ph), "Quick message %d (empty clears it)", slot + 1); + _nav_ta = wifiField(_nav_overlay, ph, false); + lv_obj_remove_event_cb(_nav_ta, onWifiField); + lv_obj_set_width(_nav_ta, w); + lv_obj_set_pos(_nav_ta, 8, theme::STATUS_H + 4); + lv_textarea_set_text(_nav_ta, msgtext::quick(_prefs, slot)); + lv_obj_add_event_cb(_nav_ta, devview::onByteCapInsert, LV_EVENT_INSERT, (void*)(uintptr_t)(msgtext::QUICK_LEN - 1)); + lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); // draws the cursor + + lv_obj_t* row = chips(_nav_overlay, onQuickEditPh, false); + lv_obj_set_width(row, w); + lv_obj_set_pos(row, 8, theme::STATUS_H + 4 + 34 + 6); + + _nav_kb = kb::create(_nav_overlay, _prefs); + lv_obj_set_size(_nav_kb, LV_PCT(100), 124); + lv_obj_align(_nav_kb, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_keyboard_set_textarea(_nav_kb, _nav_ta); + lv_obj_add_event_cb(_nav_kb, onQuickEditKb, LV_EVENT_READY, NULL); + lv_obj_add_event_cb(_nav_kb, onQuickEditKb, LV_EVENT_CANCEL, NULL); +} + +void UITask::quickEditInsert(int ph) { + using namespace qview; + if (ph >= 0 && ph < s_ph_n && _nav_ta) lv_textarea_add_text(_nav_ta, s_ph[ph]); +} + +void UITask::quickEditDone(bool ok) { + using namespace qview; + if (ok && _nav_ta && _prefs && s_edit >= 0) { + msgtext::setQuick(_prefs, s_edit, lv_textarea_get_text(_nav_ta)); + the_mesh.savePrefs(); + showToast(msgtext::quick(_prefs, s_edit)[0] ? "Quick message saved" : "Quick message cleared", 1200); + } + navClosePopup(); + if (ok && _screen == SCR_QUICK) { + lv_obj_t* body = _body; + int32_t y = body ? lv_obj_get_scroll_y(body) : 0; + buildQuickMsgs(); + if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_to_y(_body, y, LV_ANIM_OFF); } + } +} + +// ── Settings > Send advert ──────────────────────────────────────────────────── + +void UITask::advertPopup() { + using namespace qview; + lv_obj_t* panel = navPopupPanel("Send advert", false); + lv_obj_t* t = label(panel, "Tells other nodes who you are (and where, if you share your position). " + "Auto-advert can do it on a timer.", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); + lv_obj_set_width(t, LV_PCT(100)); + listRow(panel, "Nearby only", "Nodes in direct range, not repeated", onAdvertGo, (void*)(uintptr_t)0); + listRow(panel, "Everyone", "Through repeaters, across the mesh", onAdvertGo, (void*)(uintptr_t)1); +} + +void UITask::advertSend(bool flood) { + navClosePopup(); + bool ok = flood ? the_mesh.advertFlood() : the_mesh.advert(); + if (ok) notify(UIEventType::ack); + showToast(!ok ? "Advert failed" : flood ? "Advert sent across the mesh" : "Advert sent to nodes in range"); +} + +// ── Settings > Bluetooth ────────────────────────────────────────────────────── +// BLE only: the USB link to a computer stays on. Not kept over a reboot +// (Bluetooth starts on, as on L1). + +static void bluetoothRow(lv_obj_t* body) { + char sub[48]; + bool on = s_ui->isSerialEnabled(); + if (!on) snprintf(sub, sizeof(sub), "Off - the phone app can't connect"); + else if (s_ui->hasConnection()) snprintf(sub, sizeof(sub), "On - the app is connected"); + else if (the_mesh.getBLEPin()) snprintf(sub, sizeof(sub), "On - pairing PIN %06lu", (unsigned long)the_mesh.getBLEPin()); + else snprintf(sub, sizeof(sub), "On - waiting for the app"); + lv_obj_t* sw = switchRow(body, LV_SYMBOL_BLUETOOTH " Bluetooth", sub, nullptr); + if (on) lv_obj_add_state(sw, LV_STATE_CHECKED); + lv_obj_add_event_cb(sw, qview::onBtSwitch, LV_EVENT_VALUE_CHANGED, NULL); +} + +void UITask::setBluetooth(bool on) { + if (on) enableSerial(); else disableSerial(); + showToast(on ? "Bluetooth on" : "Bluetooth off - USB still works", 1500); + if (_screen == SCR_SETTINGS) { // the row's subtitle + lv_obj_t* body = _body; + int32_t y = body ? lv_obj_get_scroll_y(body) : 0; + buildSettings(); + if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_to_y(_body, y, LV_ANIM_OFF); } + } + refreshStatusBar(); +} + +// ── Messages > Read all ─────────────────────────────────────────────────────── + +void UITask::markAllRead() { + _core->markAllRead(); + showToast("All marked read", 1200); + if (_screen == SCR_CHATS) buildChats(); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 5f01584e..a239a7de 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -20,6 +20,7 @@ #include "../ui-core/BotConfig.h" #include "../ui-core/SoundControl.h" #include "../ui-core/SoundNotifier.h" +#include "../ui-core/MessageText.h" #include "Theme.h" #include "LvglPort.h" #include "../ui-core/KeyboardData.h" @@ -134,6 +135,7 @@ void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs _core = new UiCore(); _core->begin(node_prefs, sensors, this); + msgtext::seedQuick(node_prefs); // "OK" in quick message 1 on first boot _nearby = new NearbyModel(); _nearby->bindModel(_core, node_prefs); _scan = new NearbyModel(); @@ -562,6 +564,11 @@ void UITask::back() { case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break; case SCR_SETTINGS: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_SETTINGS_NAV: showSettings(); break; + case SCR_QUICK: // back to Messages & contacts' bottom, where the row is + if (_nav_overlay) { navClosePopup(); break; } + showSchemaSettings(settings::PG_MESSAGES); + if (_body) { lv_obj_update_layout(_body); lv_obj_scroll_by(_body, 0, -lv_obj_get_scroll_bottom(_body), LV_ANIM_OFF); } + break; case SCR_MELODY: // back to the Sound page's bottom, where the melodies are melodySave(); stopMelody(); @@ -863,8 +870,13 @@ void UITask::showChats() { buildChats(); } +static void onMarkAllRead(lv_event_t* e) { (void)e; s_ui->markAllRead(); } +static lv_obj_t* headerButton(lv_obj_t* hdr, const char* text, lv_event_cb_t cb, int right, lv_obj_t** label_out); + void UITask::buildChats() { lv_obj_t* body = newScreen("Messages", true); + if (_header && _core->dmUnreadTotal() + _core->history.getTotalChannelUnread() + _core->roomUnread() > 0) + headerButton(_header, LV_SYMBOL_OK " Read all", onMarkAllRead, 4, NULL); // Channels: favourites first (unless turned off), hold a row for its options bool ch_fav_only = _prefs && _prefs->ch_fav_only; @@ -1003,7 +1015,7 @@ void UITask::buildContacts() { // model, since it is a different set: who is in range, not who is known. static NearbyModel::Entry s_node; // the node open in SCR_NODE (a copy: the list re-sorts) -enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN }; +enum : uint8_t { NODE_MSG, NODE_PING, NODE_FAV, NODE_ADD, NODE_DELETE, NODE_NAV, NODE_ADMIN, NODE_WAYPOINT, NODE_PIN }; static void onNearbyChip(lv_event_t* e) { s_ui->setNearbyFilter((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } static void onNearbySort(lv_event_t* e) { (void)e; s_ui->toggleNearbySort(); } @@ -1310,14 +1322,25 @@ void UITask::buildNode() { lv_obj_set_flex_flow(acts, LV_FLEX_FLOW_ROW); lv_obj_set_style_pad_column(acts, theme::GAP, 0); bool contact = e.contact_idx >= 0; - if (contact && e.type == ADV_TYPE_CHAT) actionButton(acts, LV_SYMBOL_ENVELOPE " Message", NODE_MSG, true); - if (e.has_key) actionButton(acts, LV_SYMBOL_LOOP " Ping", NODE_PING, false); - if (e.lat_e6 != 0 || e.lon_e6 != 0) actionButton(acts, UI_SYMBOL_COMPASS, NODE_NAV, false); bool admin = contact && (e.type == ADV_TYPE_REPEATER || e.type == ADV_TYPE_ROOM); - if (contact) actionButton(acts, admin ? UI_SYMBOL_STAR : e.fav ? UI_SYMBOL_STAR " Unfav" : UI_SYMBOL_STAR " Fav", NODE_FAV, e.fav && admin); - if (admin) actionButton(acts, LV_SYMBOL_SETTINGS " Admin", NODE_ADMIN, false); - if (!contact && e.has_key && !e.is_known) actionButton(acts, LV_SYMBOL_PLUS " Add", NODE_ADD, true); - if (contact) _node_delete_lbl = actionButton(acts, LV_SYMBOL_TRASH, NODE_DELETE, false); + bool pos = e.lat_e6 != 0 || e.lon_e6 != 0; + struct Act { const char* icon; const char* text; uint8_t action; bool accent; } list[9]; + int n = 0; + if (contact && e.type == ADV_TYPE_CHAT) list[n++] = { LV_SYMBOL_ENVELOPE, " Message", NODE_MSG, true }; + if (e.has_key) list[n++] = { LV_SYMBOL_LOOP, " Ping", NODE_PING, false }; + if (pos) list[n++] = { UI_SYMBOL_COMPASS, "", NODE_NAV, false }; + if (pos) list[n++] = { UI_SYMBOL_FLAG, "", NODE_WAYPOINT, false }; // save where it was seen + if (contact) list[n++] = { UI_SYMBOL_STAR, admin ? "" : e.fav ? " Unfav" : " Fav", NODE_FAV, e.fav && admin }; + if (admin) list[n++] = { LV_SYMBOL_SETTINGS, " Admin", NODE_ADMIN, false }; + if (!contact && e.has_key && !e.is_known) list[n++] = { LV_SYMBOL_PLUS, " Add", NODE_ADD, true }; + if (contact && e.has_key) list[n++] = { UI_SYMBOL_PIN, "", NODE_PIN, favslots::findContact(_prefs, e.pub_key) >= 0 }; + if (contact) list[n++] = { LV_SYMBOL_TRASH, "", NODE_DELETE, false }; + for (int i = 0; i < n; i++) { // five or more: icons only, so every button fits one row + char t[24]; + snprintf(t, sizeof(t), "%s%s", list[i].icon, n >= 5 ? "" : list[i].text); + lv_obj_t* l = actionButton(acts, t, list[i].action, list[i].accent); + if (list[i].action == NODE_DELETE) _node_delete_lbl = l; + } refreshNode(); } @@ -1436,11 +1459,25 @@ void UITask::nodeAction(uint8_t action) { case NODE_ADMIN: openAdmin(e.pub_key); break; + case NODE_WAYPOINT: { + if (_core->waypoints.full()) { showToast("Waypoints full (16)"); break; } + char t[48]; + if (_core->waypoints.add(e.lat_e6, e.lon_e6, rtc_clock.getCurrentTime(), e.name[0] ? e.name : "Node")) { + snprintf(t, sizeof(t), "Saved %s", _core->waypoints.at(_core->waypoints.count() - 1).label); + the_mesh.savePrefs(); + showToast(t); + } + break; + } + case NODE_PIN: // to the favourites dial (Home), as from a chat's options + pinPopup(false, 0, e.pub_key); + break; case NODE_DELETE: if (!_delete_armed_ms) { // destructive: second tap within 3 s confirms _delete_armed_ms = millis(); if (_delete_armed_ms == 0) _delete_armed_ms = 1; - lv_label_set_text(_node_delete_lbl, "Delete?"); + lv_label_set_text(_node_delete_lbl, LV_SYMBOL_TRASH "?"); // fits an icon-only button + showToast("Tap again to delete the contact", 2500); break; } if (the_mesh.deleteContactByKey(e.pub_key)) { @@ -1482,6 +1519,7 @@ static void onComposeInsert(lv_event_t* e) { } static void onComposeClicked(lv_event_t* e) { (void)e; s_ui->setKeyboardVisible(true); } +static void onComposeMore(lv_event_t* e) { (void)e; s_ui->quickPopup(); } // Keyboard up: the screen header goes away and the body takes its 32 px, so // a line or two of the conversation stays visible above the compose field @@ -1566,12 +1604,21 @@ void UITask::buildThread() { lv_obj_remove_flag(bar, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_size(bar, LV_PCT(100), 42); lv_obj_set_style_pad_all(bar, 4, 0); + lv_obj_t* more = lv_button_create(bar); // quick messages, placeholders + lv_obj_set_size(more, 34, 34); + lv_obj_align(more, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_style_pad_all(more, 0, 0); + lv_obj_set_style_radius(more, theme::RADIUS, 0); + lv_obj_set_style_shadow_width(more, 0, 0); + lv_obj_set_style_bg_color(more, lv_color_hex(theme::SURFACE), 0); + lv_obj_add_event_cb(more, onComposeMore, LV_EVENT_CLICKED, NULL); + lv_obj_center(label(more, LV_SYMBOL_PLUS, THEME_FONT_BODY, theme::TEXT)); _compose_ta = lv_textarea_create(bar); lv_textarea_set_one_line(_compose_ta, true); lv_textarea_set_placeholder_text(_compose_ta, "Message"); lv_obj_add_event_cb(_compose_ta, onComposeInsert, LV_EVENT_INSERT, NULL); - lv_obj_set_size(_compose_ta, LV_PCT(100), 34); - lv_obj_align(_compose_ta, LV_ALIGN_LEFT_MID, 0, 0); + lv_obj_set_size(_compose_ta, lv_display_get_horizontal_resolution(NULL) - 8 - 34 - 4, 34); // beside "+" + lv_obj_align(_compose_ta, LV_ALIGN_RIGHT_MID, 0, 0); // The theme's padding leaves less than one line inside 36 px, which makes // the field scroll vertically (text jumps as it's typed). Pad so exactly one // body line (20 px) fits: 34 = 2*1 border + 2*6 pad + 20. @@ -1834,10 +1881,8 @@ void UITask::refreshThread() { lv_obj_scroll_to_y(_thread_list, LV_COORD_MAX, LV_ANIM_OFF); } -void UITask::sendFromCompose() { - if (!_compose_ta) return; - const char* text = lv_textarea_get_text(_compose_ta); - if (!text || !text[0]) return; +// To the open conversation (channel or DM). The caller refreshes the thread. +bool UITask::sendThreadText(const char* text) { bool ok; if (_thread_is_channel) { ok = _core->sendChannelText(_thread_channel, text); @@ -1845,13 +1890,20 @@ void UITask::sendFromCompose() { ContactInfo c; ok = MessageHistory::contactByPrefix(_thread_key, c) && _core->sendDirectText(c, text); } - if (ok) { - lv_textarea_set_text(_compose_ta, ""); - setKeyboardVisible(false); - refreshThread(); - } else { - showToast("Send failed"); - } + if (!ok) showToast("Send failed"); + return ok; +} + +void UITask::sendFromCompose() { + if (!_compose_ta) return; + const char* typed = lv_textarea_get_text(_compose_ta); + if (!typed || !typed[0]) return; + char text[MSG_TEXT_BUF]; + msgtext::expandOutgoing(typed, text, sizeof(text), _prefs); // {loc}, {time}, ... (MessageText.h) + if (!sendThreadText(text)) return; + lv_textarea_set_text(_compose_ta, ""); + setKeyboardVisible(false); + refreshThread(); } // ── Settings (first rows; the declarative schema replaces this later) ──────── @@ -1930,6 +1982,9 @@ static void onSchemaDropdown(lv_event_t* e) { } static void onOpenSchemaPage(lv_event_t* e) { s_ui->showSchemaSettings((int)(uintptr_t)lv_event_get_user_data(e)); } static void onPruneContacts(lv_event_t* e) { (void)e; s_ui->pruneContacts(); } +static void onAdvertRow(lv_event_t* e); // QuickScreen.h +static void onOpenQuickMsgs(lv_event_t* e); +static void bluetoothRow(lv_obj_t* body); static void onVolumeSlider(lv_event_t* e) { s_ui->setSoundVolume((int)lv_slider_get_value((lv_obj_t*)lv_event_get_target(e))); } @@ -2069,6 +2124,12 @@ void UITask::buildSchemaSettings() { lv_obj_center(_prune_lbl); } if (_settings_page == settings::PG_SOUND) buildSoundRows(body, false); // the melodies + if (_settings_page == settings::PG_MESSAGES) { + sectionTitle(body, "QUICK MESSAGES"); + char sub[48]; + snprintf(sub, sizeof(sub), "%d of %d set - sent with one tap", msgtext::quickUsed(_prefs), msgtext::QUICK_COUNT); + listRow(body, LV_SYMBOL_EDIT " Quick messages", sub, onOpenQuickMsgs, NULL); + } } void UITask::setSchemaValue(int idx, int v) { @@ -2099,7 +2160,9 @@ void UITask::buildSettings() { lv_obj_t* body = newScreen("Settings", true); sectionTitle(body, "NODE"); listRow(body, LV_SYMBOL_EDIT " Name", the_mesh.getNodeName(), onNodeName, NULL); + listRow(body, UI_SYMBOL_RADIO " Send advert", "Let other nodes see you now", onAdvertRow, NULL); sectionTitle(body, "CONNECTIVITY"); + bluetoothRow(body); char ssid[33], pass[65]; bool have = lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass)); listRow(body, LV_SYMBOL_WIFI " WiFi", have ? ssid : "Not set (for map downloads)", onOpenWifi, NULL); @@ -2174,3 +2237,4 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) { #include "RadioExtras.h" #include "RepeaterScreen.h" #include "SoundScreen.h" +#include "QuickScreen.h" diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index c9a323ae..5eba33f9 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -128,6 +128,18 @@ public: uint16_t buzzerFreqHz() const; uint8_t buzzerVolume() const { return _buzzer.getVolume(); } #endif + // Quick messages, placeholders, advert, Bluetooth (QuickScreen.h) + void quickPopup(); // the compose bar's "+" + void quickSend(int slot); + void quickInsert(int ph); + void showQuickMsgs(); + void quickEdit(int slot); + void quickEditDone(bool ok); + void quickEditInsert(int ph); + void advertPopup(); + void advertSend(bool flood); + void setBluetooth(bool on); + void markAllRead(); // Settings > Radio (RadioScreen.h) void showRadio(); void radioSet(int which, int v); @@ -209,7 +221,7 @@ public: void openAdminFor(const ContactInfo& ci, bool from_picker) { (void)ci; (void)from_picker; } private: - enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY }; + enum Screen : uint8_t { SCR_HOME, SCR_CHATS, SCR_CONTACTS, SCR_THREAD, SCR_SETTINGS, SCR_NEARBY, SCR_NODE, SCR_MAP, SCR_WIFI, SCR_SETTINGS_NAV, SCR_CLOCK, SCR_RADIO, SCR_CHANNEL_EDIT, SCR_ADMIN, SCR_BOT, SCR_FAVS, SCR_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK }; void buildStatusBar(); void refreshStatusBar(); @@ -269,6 +281,8 @@ private: void rebuildRepeater(); void buildSoundRows(lv_obj_t* body, bool top); void buildMelodies(); + void buildQuickMsgs(); + bool sendThreadText(const char* text); void refreshMelody(); void melodySave(); void radioCloseFreq(); diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index 99ae88d7..0d0db2ce 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -150,23 +150,8 @@ class MessagesScreen : public UIScreen { int _hist_visible = 2; // updated in render(); for history list scroll clamping - void expandMsg(const char* tmpl, char* out, int out_len) const { - double lat = 0, lon = 0; - bool gps_valid = false; -#if ENV_INCLUDE_GPS == 1 - LocationProvider* loc = sensors.getLocationProvider(); - if (loc && loc->isValid()) { - lat = loc->getLatitude() / 1000000.0; - lon = loc->getLongitude() / 1000000.0; - gps_valid = true; - } -#endif - NodePrefs* np = _task->getNodePrefs(); - float batt = (float)board.getBattMilliVolts() / 1000.0f; - ::expandMsg(tmpl, out, out_len, lat, lon, gps_valid, - rtc_clock.getCurrentTime(), - np ? np->tz_offset_hours : 0, - &sensors, batt); + void expandMsg(const char* tmpl, char* out, int out_len) const { // ui-core/MessageText.h + msgtext::expand(tmpl, out, out_len, _task->getNodePrefs()); } // Scrollbar metrics for a history list. The track is pinned to the full list diff --git a/examples/companion_radio/ui-new/SensorPlaceholders.h b/examples/companion_radio/ui-new/SensorPlaceholders.h index c1b652a7..3e533f9e 100644 --- a/examples/companion_radio/ui-new/SensorPlaceholders.h +++ b/examples/companion_radio/ui-new/SensorPlaceholders.h @@ -7,40 +7,6 @@ #include #include -inline void kbAddSensorPlaceholders(KeyboardWidget& kb, SensorManager* sm) { - if (!sm) return; - - CayenneLPP lpp(100); - sm->querySensors(0xFF, lpp); - - // collect unique LPP types that have actual data - uint8_t present[16]; - int pc = 0; - { - LPPReader r(lpp.getBuffer(), lpp.getSize()); - uint8_t ch, type; - while (r.readHeader(ch, type)) { - bool already = false; - for (int i = 0; i < pc; i++) if (present[i] == type) { already = true; break; } - if (!already && pc < 16) present[pc++] = type; - r.skipData(type); - } - } - - static const struct { uint8_t t; const char* ph; } MAP[] = { - { LPP_TEMPERATURE, "{temp}" }, - { LPP_RELATIVE_HUMIDITY, "{hum}" }, - { LPP_BAROMETRIC_PRESSURE, "{pres}" }, - { LPP_VOLTAGE, "{batt}" }, - { LPP_ALTITUDE, "{alt}" }, - { LPP_LUMINOSITY, "{lux}" }, - { LPP_DISTANCE, "{dist}" }, - { LPP_CONCENTRATION, "{co2}" }, - }; - - for (const auto& m : MAP) { - for (int i = 0; i < pc; i++) { - if (present[i] == m.t) { kb.addPlaceholder(m.ph); break; } - } - } +inline void kbAddSensorPlaceholders(KeyboardWidget& kb, SensorManager* sm) { // list: ui-core/MessageText.h + msgtext::sensorPlaceholders(sm, [](const char* ph, void* k) { ((KeyboardWidget*)k)->addPlaceholder(ph); }, &kb); } diff --git a/examples/companion_radio/ui-new/UITask.cpp b/examples/companion_radio/ui-new/UITask.cpp index dd7a5b2c..730a4b64 100644 --- a/examples/companion_radio/ui-new/UITask.cpp +++ b/examples/companion_radio/ui-new/UITask.cpp @@ -159,6 +159,7 @@ static const int QUICK_MSGS_MAX = 10; // second .cpp is a duplicate-symbol link error. Keep them UITask-internal; // anything genuinely shareable belongs in a real header (icons.h, GeoUtils.h). #include "FullscreenMsgView.h" +#include "../ui-core/MessageText.h" #include "SensorPlaceholders.h" #include "SettingsScreen.h" #include "../ui-core/UiCore.h" // shared UI Core: history rings + unread models (MessagesScreen views them) @@ -1599,10 +1600,7 @@ void UITask::begin(DisplayDriver* display, SensorManager* sensors, NodePrefs* no vibration.begin(); #endif - // Set default quick message if slot 0 is empty (first boot) - if (_node_prefs && _node_prefs->custom_msgs[0][0] == '\0') { - strncpy(_node_prefs->custom_msgs[0], "OK", sizeof(_node_prefs->custom_msgs[0]) - 1); - } + msgtext::seedQuick(_node_prefs); // "OK" in quick message 1 on first boot ui_started_at = millis(); _alert_expiry = 0;