feat(ui-lvgl): roadmap stage 1 - keyboard close key, all bot placeholders, WAKE/USER button roles with hold-to-mute, WiFi switch, shorter descriptions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-26 01:23:54 +02:00
co-authored by Claude Opus 5.5
parent f790631e03
commit ba4d01aa77
19 changed files with 268 additions and 84 deletions
@@ -66,6 +66,15 @@ 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
## Roadmap stage 1: quick fixes (2026-09-26)
- [ ] Keyboard: the keyboard key (bottom left) closes it; in a chat the typed text stays, in a dialog nothing is saved
- [ ] Symbols page ("1#"): "_" is in the third row now
- [ ] Bot > Reply: every placeholder in the row above the field ({name} {hops} {loc} {time} {batt}, sensors); swipe it sideways
- [ ] WAKE button (side): screen off / on; silences a ringing alarm
- [ ] USER button: back (answers at once, no double-click wait); held 1 s, sound off / on with a toast and the mute icon; wakes the screen when it's off
- [ ] Settings > CONNECTIVITY > WiFi: a switch as on Bluetooth (toast, row says "Off" / the network); tapping the row opens the network settings (editable while off; Scan asks to turn it on); off: map download says "WiFi is off"; kept across a reboot
- [ ] Shorter descriptions on settings rows, Bot, Repeater, Send advert, Bluetooth ("PIN 123456" / "App connected")
## 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
+67
View File
@@ -0,0 +1,67 @@
# Wio Tracker L2 (ui-lvgl) roadmap
The L1 → L2 feature port and its audit are done (2026-09-26). This is the plan
for what comes next, in order. Tick items off as they land.
Decisions already made:
- **Buttons:** WAKE (side, expander P00) turns the screen off / on. USER/BOOT
goes back; holding it mutes / unmutes the device.
- **Placeholders:** the "+" popup next to a text field is the placeholder UI;
it must offer every placeholder and appear at every field that sends text.
- **Message history:** kept on the SD card, about 100 per conversation to
start with.
- **OTA:** firmware comes from this fork's GitHub Releases.
- **PIN:** screen lock only, for now.
## 1. Quick fixes
- [x] Keyboard: a key that closes it (so far a message could only be sent).
- [x] "+" placeholders at every field that sends text (compose, quick
messages, bot replies).
- [x] Short, terse descriptions on buttons and rows.
- [x] WiFi off switch.
- [x] Buttons as decided above.
## 2. Arduino-ESP32 3.x, PSRAM
- [ ] A separate L2 env on pioarduino (as `esp32c6_base`); the 2.0.17 env
stays until the new one passes the hardware checklist.
- [ ] Port: speaker (new I2S driver), BLE, ESP-NOW, HTTPS map download,
LovyanGFX.
- [ ] Large buffers in PSRAM: message history, trail, tile cache. Measure
internal RAM before / after.
- [ ] Full hardware checklist, then make the new env the default.
## 3. Data on the SD card
- [ ] Message history on SD, kept across reboots (~100 per conversation;
the newest ones cached in PSRAM).
- [ ] Trail sized to the device: thousands of points in PSRAM (now 512),
saved to SD.
## 4. UI layout
- [ ] Every tool as its own Home tile (trail, live share, arrival alert,
repeater, admin, diagnostics, ...), not inside Settings.
- [ ] Settings in a sensible order, as on the original (L1).
- [ ] Every status icon the original has (BT, GPS, live share, trail,
repeater, alarm, mute, arrival alert, ...).
## 5. Security and internet
- [ ] Screen-lock PIN.
- [ ] Map tiles fetched live while online.
- [ ] One-button OTA from GitHub Releases (release asset + partition layout
check).
## 6. Theme
- [ ] Develop the current look into a consistent, recognisable style
(colours, corners, type, icons, states), after the layout of stage 4.
## 7. Research: LVGL for the other displays
- [ ] Find out whether LVGL on every display variant gives consistency and a
nicer look more easily. Adopt only if the result is better and every
feature is kept (L1: 128×64 OLED, nRF52, RAM already 68% used).
+14 -14
View File
@@ -24,30 +24,30 @@ struct Row { Kind kind; const char* label; const char* hint; };
static const Row CHANNEL_ROWS[] = {
{ ENABLE_CH, "Enable", "Answer on one channel" },
{ CHANNEL, "Channel", nullptr },
{ COMMANDS_CH, "Commands", "!ping, !help and other ! commands" },
{ ACTIONS_CH, "Actions", "!gps, !gpio... (change this device)" },
{ TRIGGER_CH, "Trigger", "Text that makes it reply; * = any" },
{ REPLY_CH, "Reply", "{name} {hops} {loc} {time} ..." },
{ COMMANDS_CH, "Commands", "!ping, !help, ..." },
{ ACTIONS_CH, "Actions", "!gps, !gpio (this device)" },
{ TRIGGER_CH, "Trigger", "Reply trigger; * = any" },
{ REPLY_CH, "Reply", "{name} {loc} {time} ..." },
};
static const Row ROOM_ROWS[] = {
{ ENABLE_ROOM, "Enable", "Answer in one room server" },
{ ROOM, "Room", nullptr },
{ COMMANDS_ROOM, "Commands", "!ping, !help and other ! commands" },
{ ACTIONS_ROOM, "Actions", "!gps, !gpio... (change this device)" },
{ TRIGGER_ROOM, "Trigger", "Text that makes it reply; * = any" },
{ REPLY_ROOM, "Reply", "{name} {hops} {loc} {time} ..." },
{ COMMANDS_ROOM, "Commands", "!ping, !help, ..." },
{ ACTIONS_ROOM, "Actions", "!gps, !gpio (this device)" },
{ TRIGGER_ROOM, "Trigger", "Reply trigger; * = any" },
{ REPLY_ROOM, "Reply", "{name} {loc} {time} ..." },
};
static const Row DIRECT_ROWS[] = {
{ ENABLE_DM, "Enable", "Answer direct messages" },
{ DM_SCOPE, "DM allow", "Everyone, or favourites only" },
{ COMMANDS_DM, "Commands", "!ping, !help and other ! commands" },
{ ACTIONS_DM, "Actions", "!gps, !gpio... (change this device)" },
{ TRIGGER_DM, "Trigger", "Text that makes it reply; * = any" },
{ REPLY_DM, "Reply", "{name} {hops} {loc} {time} ..." },
{ COMMANDS_DM, "Commands", "!ping, !help, ..." },
{ ACTIONS_DM, "Actions", "!gps, !gpio (this device)" },
{ TRIGGER_DM, "Trigger", "Reply trigger; * = any" },
{ REPLY_DM, "Reply", "{name} {loc} {time} ..." },
};
static const Row OTHER_ROWS[] = {
{ QUIET_FROM, "Quiet from", "No replies between these hours" },
{ QUIET_TO, "Quiet to", "Same hour on both = always on" },
{ QUIET_FROM, "Quiet from", "No replies in these hours" },
{ QUIET_TO, "Quiet to", "Same hour = always on" },
};
static int rowCount(int tab) {
@@ -154,15 +154,15 @@ static void applySound(UiCore& c) { c.host()->applySoundPrefs(); }
#define SW(label, hint, sec, f, chg) { label, hint, sec, NP_OFF(f), 2, nullptr, chg, nullptr, 1 }
#define MAP(label, hint, sec, f, vals, opt, chg) { label, hint, sec, NP_OFF(f), COUNT_OF(vals), opt, chg, vals, NP_SIZE(f) }
static const Setting ALL[] = {
IDX("Point spacing", "Distance between trail points", SEC_TRAIL, trail_min_delta_idx,
IDX("Point spacing", "Between trail points", SEC_TRAIL, trail_min_delta_idx,
TrailStore::MIN_DELTA_COUNT, optMinDelta, nullptr),
IDX("Auto-pause", "Pause the trail when standing still", SEC_TRAIL, trail_autopause_idx,
IDX("Auto-pause", "Pause when standing still", SEC_TRAIL, trail_autopause_idx,
NodePrefs::TRAIL_AUTOPAUSE_COUNT, optAutoPause, nullptr),
SW("Save on low battery", "Keep the trail when the battery runs out", SEC_TRAIL, trail_autosave_lowbatt, nullptr),
IDX("Waypoint averaging", "Average GPS fixes when marking", SEC_TRAIL, gps_avg_idx,
SW("Save on low battery", "Save it on low battery", SEC_TRAIL, trail_autosave_lowbatt, nullptr),
IDX("Waypoint averaging", "Average fixes when marking", SEC_TRAIL, gps_avg_idx,
NodePrefs::GPS_AVG_COUNT, optGpsAvg, nullptr),
SW("Show others' positions", "People sharing [LOC] on the map", SEC_LIVE_SHARE, track_shared_loc, nullptr),
SW("Show others' positions", "Shared positions on the map", SEC_LIVE_SHARE, track_shared_loc, nullptr),
IDX("Stop sharing after", nullptr, SEC_LIVE_SHARE, loc_share_duration_idx,
NodePrefs::LOC_SHARE_DURATION_COUNT, optDuration, restartShareClock),
IDX("Send after moving", nullptr, SEC_LIVE_SHARE, loc_share_move_idx,
@@ -179,15 +179,15 @@ static const Setting ALL[] = {
NodePrefs::LOCATOR_RADIUS_COUNT, optRadius, rearmLocator),
IDX("Alert on", nullptr, SEC_LOCATOR, locator_mode,
NodePrefs::LOCATOR_MODE_COUNT, optLocMode, rearmLocator),
SW("Proximity beeper", "Faster ticks as you get closer", SEC_LOCATOR, locator_beeper, nullptr),
SW("Proximity beeper", "Ticks faster when closer", SEC_LOCATOR, locator_beeper, nullptr),
SW("Imperial units", "Miles and feet", SEC_UNITS, units_imperial, nullptr),
IDX("Brightness", nullptr, SEC_DISPLAY, display_brightness, 5, optBrightness, applyDisplay),
MAP("Screen off after", "Without a touch", SEC_DISPLAY, auto_off_secs, AUTO_OFF, optAutoOff, nullptr),
MAP("Wake on message", "Turn the screen on for new messages", SEC_DISPLAY, msg_wake_screen_off, INVERTED,
MAP("Wake on message", "Screen on for new messages", SEC_DISPLAY, msg_wake_screen_off, INVERTED,
nullptr, nullptr),
SW("Lock screen", "Slide to unlock after the screen turns off", SEC_DISPLAY, auto_lock, nullptr),
SW("Lock screen", "Slide to unlock after sleep", SEC_DISPLAY, auto_lock, nullptr),
IDX("Battery display", "In the status bar", SEC_DISPLAY, batt_display_mode, 3, optBattDisplay, nullptr),
MAP("Battery shutdown", "Power off below this voltage", SEC_POWER, low_batt_mv, LOW_BATT, optLowBatt, nullptr),
@@ -195,12 +195,12 @@ static const Setting ALL[] = {
MAP("Time zone", "For the clock and the alarm", SEC_TIME, tz_offset_hours, TZ, optTz, nullptr),
SW("12-hour clock", "AM / PM instead of 24 h", SEC_TIME, clock_12h, nullptr),
MAP("Clock seconds", "On the home and lock screen clock", SEC_TIME, clock_hide_seconds, INVERTED, nullptr, nullptr),
MAP("Clock seconds", "On the home clock", SEC_TIME, clock_hide_seconds, INVERTED, nullptr, nullptr),
IDX("Resend direct messages", "Extra tries without a delivery tick", SEC_MESSAGES, dm_resend_count, 6,
IDX("Resend direct messages", "Retries without a tick", SEC_MESSAGES, dm_resend_count, 6,
optResend, nullptr),
IDX("Contact expiry", "Inactive this long can be pruned", SEC_CONTACTS, contact_expiry_idx,
IDX("Contact expiry", "Prune after inactivity", SEC_CONTACTS, contact_expiry_idx,
NodePrefs::CONTACT_EXPIRY_COUNT, optExpiry, nullptr),
MAP("Favourites first", "In contact and node lists", SEC_CONTACTS, fav_sort_off, INVERTED, nullptr, nullptr),
@@ -179,7 +179,7 @@ void UITask::adminRow(int row) {
if (prompt) { // takes the node out of service for a while: ask first
s_confirm = &f;
lv_obj_t* panel = navPopupPanel(prompt, false);
lv_obj_t* t = label(panel, "The node is offline until it comes back; nobody can reach it through it meanwhile.",
lv_obj_t* t = label(panel, "It's offline until it comes back.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
@@ -296,7 +296,7 @@ void UITask::adminValuePopup() {
lv_obj_add_event_cb(s_choice, onAdminChoice, LV_EVENT_VALUE_CHANGED, NULL);
}
if (f->isRadio()) {
lv_obj_t* note = label(panel, "The node leaves your network if it no longer matches it.", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_obj_t* note = label(panel, "A mismatch cuts the node off.", THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP);
lv_obj_set_width(note, LV_PCT(100));
}
+21 -13
View File
@@ -33,10 +33,22 @@ static void onBotScope(lv_event_t* e) {
static void onBotPick(lv_event_t* e) { s_ui->botPick((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onBotHour(lv_event_t* e) { s_ui->botHour((uint8_t)(uintptr_t)lv_event_get_user_data(e)); }
static void onBotTextKb(lv_event_t* e) { s_ui->botTextDone(lv_event_get_code(e) == LV_EVENT_READY); }
static lv_obj_t* s_chip_ta = nullptr; // the reply field the chips type into
static void onBotPlaceholder(lv_event_t* e) {
lv_obj_t* ta = (lv_obj_t*)lv_event_get_user_data(e);
lv_obj_t* btn = (lv_obj_t*)lv_event_get_target(e);
lv_textarea_add_text(ta, lv_label_get_text(lv_obj_get_child(btn, 0)));
if (s_chip_ta) lv_textarea_add_text(s_chip_ta, lv_label_get_text(lv_obj_get_child(btn, 0)));
}
// A placeholder button on `parent` (the reply editor's chips row).
static void botChip(const char* ph, void* parent) {
lv_obj_t* b = lv_button_create((lv_obj_t*)parent);
lv_obj_set_height(b, 26);
lv_obj_set_style_pad_hor(b, 7, 0);
lv_obj_set_style_pad_ver(b, 0, 0);
lv_obj_set_style_radius(b, 13, 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, onBotPlaceholder, LV_EVENT_CLICKED, NULL);
lv_obj_center(label(b, ph, THEME_FONT_SMALL, theme::TEXT));
}
static void onOpenBot(lv_event_t* e) { (void)e; s_ui->showBot(); }
@@ -166,17 +178,13 @@ void UITask::botRow(int row) {
lv_obj_set_flex_flow(chips, LV_FLEX_FLOW_ROW);
lv_obj_set_flex_align(chips, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
lv_obj_set_style_pad_gap(chips, 4, 0);
for (int i = 0; i < botcfg::REPLY_PLACEHOLDER_COUNT - 1; i++) { // {batt} and sensors: type them
lv_obj_t* b = lv_button_create(chips);
lv_obj_set_height(b, 26);
lv_obj_set_style_pad_hor(b, 7, 0);
lv_obj_set_style_pad_ver(b, 0, 0);
lv_obj_set_style_radius(b, 13, 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, onBotPlaceholder, LV_EVENT_CLICKED, _nav_ta);
lv_obj_center(label(b, botcfg::REPLY_PLACEHOLDERS[i], THEME_FONT_SMALL, theme::TEXT));
}
lv_obj_set_scroll_dir(chips, LV_DIR_HOR); // swipe for the rest
lv_obj_set_scrollbar_mode(chips, LV_SCROLLBAR_MODE_OFF);
s_chip_ta = _nav_ta;
for (int i = 0; i < botcfg::REPLY_PLACEHOLDER_COUNT; i++) botChip(botcfg::REPLY_PLACEHOLDERS[i], chips);
msgtext::sensorPlaceholders(&sensors, [](const char* ph, void* c) {
if (strcmp(ph, "{batt}") != 0) botChip(ph, c); // already there
}, chips);
}
_nav_kb = kb::create(_nav_overlay, _prefs);
lv_obj_set_size(_nav_kb, LV_PCT(100), 124);
@@ -91,7 +91,7 @@ void UITask::refreshDiag() {
void UITask::diagResetPopup() {
lv_obj_t* panel = navPopupPanel("Reset counters?", false);
lv_obj_t* t = label(panel, "Zeroes the packet counts, forwarded count and radio errors.", THEME_FONT_SMALL,
lv_obj_t* t = label(panel, "Zeroes packet and error counts.", THEME_FONT_SMALL,
theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
+8 -5
View File
@@ -7,7 +7,9 @@
// - hold a key for its variants (ą ć ę … / ё є ї … / ά έ … / punctuation
// behind '.'), from the shared table in ui-core/KeyboardData.h. The popup
// opens above the key; tap a variant to type it, tap elsewhere to close.
// - one-shot shift; digits / symbols page behind "1#".
// - one-shot shift; digits / symbols page behind "1#"; the keyboard key
// closes it (LV_EVENT_CANCEL: the compose bar keeps its text, a dialog
// is left without saving).
// Letters type on release (popover keys), so a long press never types the
// base letter first.
//
@@ -18,6 +20,7 @@ namespace kb {
#define KB_BS LV_SYMBOL_BACKSPACE
#define KB_OK LV_SYMBOL_OK
#define KB_SH LV_SYMBOL_UP
#define KB_HIDE LV_SYMBOL_KEYBOARD // closes it: lv_keyboard_def_event_cb sends LV_EVENT_CANCEL
static const char* const K_SYM = "1#";
static const char* const K_BACK = "abc";
@@ -56,7 +59,7 @@ static const char* const GRK_UP[] = {
static const char* const SYM[] = {
"1","2","3","4","5","6","7","8","9","0","\n",
"-","/",":",";","(",")","€","&","@","\"","\n",
".",",","?","!","'","#","%","+","=",KB_BS,"\n",
".",",","?","!","'","#","%","+","=","_",KB_BS,"\n",
nullptr };
// Layout ids; letter layouts in (lower, upper) pairs, in NodePrefs
@@ -88,7 +91,7 @@ static uint8_t altScript() {
static bool hasAlt() { return altScript() != mainScript(); }
static bool isSpecial(const char* t) {
static const char* const SP[] = { KB_SH, KB_BS, KB_OK, K_SYM, K_BACK, K_GLOBE };
static const char* const SP[] = { KB_SH, KB_BS, KB_OK, KB_HIDE, K_SYM, K_BACK, K_GLOBE };
for (const char* s : SP) if (strcmp(t, s) == 0) return true;
return false;
}
@@ -102,10 +105,10 @@ static void buildMaps() {
int m = 0;
for (const char* const* p = ROWS[l]; *p; p++) s_maps[l][m++] = *p;
if (l == L_SYM) {
s_maps[l][m++] = K_BACK; s_maps[l][m++] = "_"; s_maps[l][m++] = " ";
s_maps[l][m++] = KB_HIDE; s_maps[l][m++] = K_BACK; s_maps[l][m++] = " ";
s_maps[l][m++] = "*"; s_maps[l][m++] = KB_OK;
} else {
s_maps[l][m++] = K_SYM;
s_maps[l][m++] = KB_HIDE; s_maps[l][m++] = K_SYM;
if (alt) s_maps[l][m++] = K_GLOBE;
s_maps[l][m++] = " "; s_maps[l][m++] = "."; s_maps[l][m++] = KB_OK;
}
@@ -122,6 +122,21 @@ static void saveWifi(const char* ssid, const char* pass) {
p.putString("pass", pass);
p.end();
}
// Settings > WiFi's switch: off keeps the radio off for everything (scan, map
// download). Kept with the credentials.
static bool wifiAllowed() {
Preferences p;
if (!p.begin("mc_wifi", true)) return true;
bool on = p.getBool("on", true);
p.end();
return on;
}
static void setWifiAllowed(bool on) {
Preferences p;
if (!p.begin("mc_wifi", false)) return;
p.putBool("on", on);
p.end();
}
static void netBegin(const char* ssid, const char* pass) {
WiFi.mode(WIFI_STA);
@@ -350,6 +365,9 @@ static void saveWifi(const char* ssid, const char* pass) {
snprintf(s_ssid, sizeof(s_ssid), "%s", ssid);
snprintf(s_pass, sizeof(s_pass), "%s", pass);
}
static bool s_wifi_on = true;
static bool wifiAllowed() { return s_wifi_on; }
static void setWifiAllowed(bool on) { s_wifi_on = on; }
static void netBegin(const char*, const char*) {}
static int netState() { return NET_UP; }
static void netEnd() {}
@@ -728,6 +728,7 @@ void UITask::refreshDownloadPopup() {
void UITask::mapDownloadStart() {
if (mapview::s_dl.active()) { mapDownloadStop(); return; }
char ssid[33], pass[65];
if (!lvport::wifiAllowed()) { showToast("WiFi is off - Settings > WiFi"); return; }
if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) { showWifi(true); return; }
if (!lvport::mountStorage()) { showToast("No SD card"); return; }
int w = _map_area ? lv_obj_get_width(_map_area) : 320, h = _map_area ? lv_obj_get_height(_map_area) : 218;
@@ -740,6 +741,7 @@ void UITask::mapDownloadResume() {
mapview::TileArea a;
if (mapview::s_dl.active() || !mapview::s_dl.savedJob(a)) return;
char ssid[33], pass[65];
if (!lvport::wifiAllowed()) { showToast("WiFi is off - Settings > WiFi"); return; }
if (!lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass))) { showWifi(true); return; }
if (!lvport::mountStorage()) { showToast("No SD card"); return; }
if (!mapview::s_dl.start(a, ssid, pass)) { showToast(mapview::s_dl.message()); return; }
@@ -107,7 +107,7 @@ void UITask::showQuickMsgs() {
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.",
lv_obj_t* t = label(body, "Sent from a chat's \"+\"; placeholders are filled in.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
@@ -185,12 +185,11 @@ void UITask::quickEditDone(bool ok) {
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_obj_t* t = label(panel, "Shows you (and your position, if shared) to other nodes.", 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);
listRow(panel, "Nearby only", "Direct range only", onAdvertGo, (void*)(uintptr_t)0);
listRow(panel, "Everyone", "Across the mesh", onAdvertGo, (void*)(uintptr_t)1);
}
void UITask::advertSend(bool flood) {
@@ -207,10 +206,10 @@ void UITask::advertSend(bool flood) {
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");
if (!on) snprintf(sub, sizeof(sub), "Off");
else if (s_ui->hasConnection()) snprintf(sub, sizeof(sub), "App connected");
else if (the_mesh.getBLEPin()) snprintf(sub, sizeof(sub), "PIN %06lu", (unsigned long)the_mesh.getBLEPin());
else snprintf(sub, sizeof(sub), "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);
@@ -131,7 +131,7 @@ void UITask::buildRadio() {
if (p->client_repeat) lv_obj_add_state(sw, LV_STATE_DISABLED);
lv_obj_add_event_cb(sw, onRadioSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)R_APC);
lv_obj_t* note = label(body, "Everyone you talk to must use the same frequency, SF, bandwidth and coding rate.",
lv_obj_t* note = label(body, "Everyone you talk to needs the same settings.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(note, LV_LABEL_LONG_WRAP);
lv_obj_set_width(note, LV_PCT(100));
@@ -65,7 +65,7 @@ void UITask::buildRepeater() {
lv_obj_align(sw, LV_ALIGN_RIGHT_MID, -theme::PAD, 0);
if (p->client_repeat) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onRptSwitch, LV_EVENT_VALUE_CHANGED, (void*)(uintptr_t)RP_ON);
lv_obj_t* t = label(body, "While on, auto power and RX power saving pause. Relaying uses more battery.",
lv_obj_t* t = label(body, "Uses more battery; auto power pauses.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
@@ -112,7 +112,7 @@ void UITask::buildRepeater() {
}
sectionTitle(body, "WHAT IT RELAYS");
switchRow(body, "Skip adverts", "Messages still relay, adverts don't", &p->repeat_skip_adverts);
switchRow(body, "Skip adverts", "Relay messages, not adverts", &p->repeat_skip_adverts);
char v[12];
int o = 0;
for (int i = 0; i <= rptctl::MAX_HOPS; i++) {
@@ -135,7 +135,7 @@ void UITask::buildRepeater() {
}
rowDropdown(settingRow(body, "Min SNR", "Ignore weaker packets"), s_opts, rptctl::snrToChoice(p->repeat_min_snr), 100,
onRptDropdown, RP_SNR);
switchRow(body, "Skip duplicates", "Cancel if another node relays it first", &p->repeat_suppress_dup);
switchRow(body, "Skip duplicates", "Skip if already relayed", &p->repeat_suppress_dup);
switchRow(body, "Scope only", "Only floods in your scopes", &p->repeat_scope_only);
char sub[48];
extraScopesSummary(p, sub, sizeof(sub));
@@ -178,7 +178,7 @@ void UITask::repeaterScopesPopup() {
if (sl.count == 0) { showToast("Set up scopes in Settings > Radio first"); return; }
lv_obj_t* panel = navPopupPanel("Extra scopes", sl.count > 2);
if (sl.count > 2) lv_obj_add_flag(panel, LV_OBJ_FLAG_SCROLLABLE); // up to 8 rows
lv_obj_t* t = label(panel, "Relayed besides the default scope. With Scope only off, everything is relayed anyway.",
lv_obj_t* t = label(panel, "Relayed besides the default scope.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
@@ -281,7 +281,7 @@ void UITask::buildSoundRows(lv_obj_t* body, bool top) {
else snprintf(sub, sizeof(sub), "Empty - tap to compose");
listRow(body, slot ? LV_SYMBOL_AUDIO " Melody 2" : LV_SYMBOL_AUDIO " Melody 1", sub, onMelodyRow, (void*)(uintptr_t)slot);
}
lv_obj_t* t = label(body, "Pick them above for each kind of message, or for one chat in its options (hold it in Chats).",
lv_obj_t* t = label(body, "Per chat: hold it in Chats for its options.",
THEME_FONT_SMALL, theme::TEXT_MUTED);
lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP);
lv_obj_set_width(t, LV_PCT(100));
+43 -18
View File
@@ -35,8 +35,10 @@ static UITask* s_ui = nullptr; // for LVGL's C callbacks
#if defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
#include <emscripten.h>
// The board's one button, pressed from the simulator page (web/lvgl.html).
static bool s_sim_btn_click = false;
static bool s_sim_btn_click = false, s_sim_btn_hold = false, s_sim_wake = false;
extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button() { s_sim_btn_click = true; }
extern "C" EMSCRIPTEN_KEEPALIVE void sim_lcd_button_hold() { s_sim_btn_hold = true; }
extern "C" EMSCRIPTEN_KEEPALIVE void sim_wake_button() { s_sim_wake = true; }
// The speaker, polled every frame by the page's Web Audio oscillator.
extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_is_playing() { return s_ui && s_ui->isBuzzerPlaying() ? 1 : 0; }
extern "C" EMSCRIPTEN_KEEPALIVE int sim_buzzer_freq_hz() { return s_ui ? (int)s_ui->buzzerFreqHz() : 0; }
@@ -200,23 +202,48 @@ void UITask::begin(DisplayDriver* display_drv, SensorManager* sensors, NodePrefs
MyMesh::Listener* UITask::meshListener() { return _core; }
// The USER button's hold: sound on / off (leaves Auto, as the Sound page's
// On / Off would).
void UITask::toggleMute() {
#ifdef PIN_BUZZER
if (!_prefs) return;
bool on = soundctl::mode(_prefs) != soundctl::MODE_ON;
soundctl::setMode(_prefs, _buzzer, on ? soundctl::MODE_ON : soundctl::MODE_OFF, isClientConnected());
the_mesh.savePrefs();
if (on) _buzzer.playForced(soundctl::MEL_VOLUME);
if (!_asleep) { showToast(on ? "Sound on" : "Sound off", 1200); refreshStatusBar(); }
#endif
}
void UITask::loop() {
pollConnection();
drainCoreEvents();
bool btn_click = false;
// USER (BOOT) button: back; held, mutes / unmutes. WAKE button: screen off /
// on. Either one silences a ringing alarm first.
bool btn_click = false, btn_hold = false, wake_press = false;
#ifdef PIN_USER_BTN
btn_click = user_btn.check() == BUTTON_EVENT_CLICK;
int ev = user_btn.check();
btn_click = ev == BUTTON_EVENT_CLICK;
btn_hold = ev == BUTTON_EVENT_LONG_PRESS;
#elif defined(SIM_PLATFORM) && defined(__EMSCRIPTEN__)
btn_click = s_sim_btn_click;
s_sim_btn_click = false;
btn_hold = s_sim_btn_hold;
wake_press = s_sim_wake;
s_sim_btn_click = s_sim_btn_hold = s_sim_wake = false;
#endif
if (btn_click) {
if (_core->clock.isRinging()) dismissRing();
else if (_asleep) wake();
else if (_screen == SCR_HOME || locked()) sleep();
else back();
#if defined(SEEED_WIO_TRACKER_L2)
if ((int32_t)(millis() - _next_wake_poll_ms) >= 0) { // an I2C read on the expander
_next_wake_poll_ms = millis() + 50;
bool down = board.readWakeButton();
wake_press = down && !_wake_down;
_wake_down = down;
}
#endif
if ((btn_click || btn_hold || wake_press) && _core->clock.isRinging()) dismissRing();
else if (wake_press) { if (_asleep) wake(); else sleep(); }
else if (btn_hold) toggleMute();
else if (btn_click) { if (_asleep) wake(); else if (!locked()) back(); }
if (_asleep) {
if (lvport::touched()) { lvport::swallowTouch(); wake(); }
@@ -1521,6 +1548,7 @@ void UITask::nodeAction(uint8_t action) {
}
}
static lv_obj_t* switchRow(lv_obj_t* parent, const char* text, const char* sub, uint8_t* pref); // below
#include "MapScreen.h"
#include "NavMap.h"
#include "ClockScreen.h"
@@ -2172,7 +2200,6 @@ void UITask::showSettings() {
buildSettings();
}
static void onOpenWifi(lv_event_t* e) { (void)e; s_ui->showWifi(false); }
static void onOpenRadio(lv_event_t* e); // RadioScreen.h
static void onOpenRepeater(lv_event_t* e); // RepeaterScreen.h
static void repeaterSummary(const NodePrefs* p, char* b, int n);
@@ -2184,9 +2211,7 @@ void UITask::buildSettings() {
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);
wifiRow(body);
if (_prefs) {
char sub[48];
int pi = radioctl::currentPreset(_prefs);
@@ -2200,16 +2225,16 @@ void UITask::buildSettings() {
if (_prefs) { // the same NodePrefs switches as ui-new's Home GPS toggle / Live share / Locator screens
sectionTitle(body, "NAVIGATION");
if (_core->gpsAvailable()) {
lv_obj_t* sw = switchRow(body, "GPS", "Position for maps, sharing and adverts", nullptr);
lv_obj_t* sw = switchRow(body, "GPS", "For maps and sharing", nullptr);
if (_core->gpsEnabled()) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onGpsSwitch, LV_EVENT_VALUE_CHANGED, NULL);
}
listRow(body, UI_SYMBOL_COMPASS " Trail, live share, alerts", "Trail, sharing, auto-advert, radius",
listRow(body, UI_SYMBOL_COMPASS " Trail, live share, alerts", "Trail, sharing, alerts",
onOpenSchemaPage, (void*)(uintptr_t)settings::PG_NAV);
sectionTitle(body, "DEVICE");
listRow(body, LV_SYMBOL_EYE_OPEN " Display & power", "Brightness, screen off, battery, GPS, time zone",
listRow(body, LV_SYMBOL_EYE_OPEN " Display & power", "Screen, battery, GPS, time",
onOpenSchemaPage, (void*)(uintptr_t)settings::PG_DEVICE);
listRow(body, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resending, contact expiry, sorting",
listRow(body, LV_SYMBOL_ENVELOPE " Messages & contacts", "Resend, expiry, sorting",
onOpenSchemaPage, (void*)(uintptr_t)settings::PG_MESSAGES);
char sub[48], vol[12];
settings::optVolume(_prefs->buzzer_volume, vol, sizeof(vol), *_prefs);
@@ -2229,7 +2254,7 @@ void UITask::buildSettings() {
label(body, "Hold a letter for accents and other variants.", THEME_FONT_SMALL, theme::TEXT_MUTED);
sectionTitle(body, "SYSTEM");
listRow(body, UI_SYMBOL_CHART " Diagnostics", "Packet counts, memory, radio, firmware", onOpenDiag, NULL);
listRow(body, UI_SYMBOL_CHART " Diagnostics", "Packets, memory, firmware", onOpenDiag, NULL);
listRow(body, LV_SYMBOL_REFRESH " Reboot", NULL, onPowerRow, (void*)(uintptr_t)1);
listRow(body, LV_SYMBOL_POWER " Power off", NULL, onPowerRow, (void*)(uintptr_t)0);
@@ -83,6 +83,7 @@ public:
void shareToMessage(const char* text);
void messageLocationAction(int idx, bool save);
void showWifi(bool from_map);
void wifiSetAllowed(bool on);
void wifiScan();
void wifiPick(int idx);
void wifiSave();
@@ -313,6 +314,9 @@ private:
void onMessageArrived(const UiEvent& ev);
void wake();
void sleep();
void toggleMute();
uint32_t _next_wake_poll_ms = 0;
bool _wake_down = false;
uint32_t autoOffMillis() const;
void checkLowBattery();
+41 -2
View File
@@ -3,7 +3,7 @@
// network map downloads connect to. Scan lists nearby networks to pick from;
// the password field uses the same keyboard as compose. Saved through
// lvport::saveWifi() (NVS on the board). WiFi itself stays off except while a
// download runs.
// download or scan runs; Settings > CONNECTIVITY's WiFi switch forbids even that.
//
// Single-TU fragment: included by ui-lvgl/UITask.cpp after MapScreen.h.
@@ -12,6 +12,9 @@ static char s_wifi_names[lvport::WIFI_SCAN_MAX][33];
static void onWifiScan(lv_event_t* e) { (void)e; s_ui->wifiScan(); }
static void onWifiSave(lv_event_t* e) { (void)e; s_ui->wifiSave(); }
static void onWifiPick(lv_event_t* e) { s_ui->wifiPick((int)(uintptr_t)lv_event_get_user_data(e)); }
static void onWifiAllowed(lv_event_t* e) {
s_ui->wifiSetAllowed(lv_obj_has_state((lv_obj_t*)lv_event_get_target(e), LV_STATE_CHECKED));
}
static void onWifiField(lv_event_t* e) { s_ui->wifiEdit((lv_obj_t*)lv_event_get_target(e)); }
static void onWifiKb(lv_event_t* e) {
if (lv_event_get_code(e) == LV_EVENT_READY) s_ui->wifiSave();
@@ -34,7 +37,8 @@ void UITask::showWifi(bool from_map) {
void UITask::buildWifi() {
lv_obj_t* body = newScreen("WiFi", true);
lv_obj_set_style_pad_row(body, 4, 0);
label(body, "Used only to download maps; off otherwise.", THEME_FONT_SMALL, theme::TEXT_MUTED);
label(body, lvport::wifiAllowed() ? "Used only for map downloads." : "WiFi is off (Settings).", THEME_FONT_SMALL,
theme::TEXT_MUTED);
lv_obj_t* row = lv_obj_create(body);
styleSurface(row, theme::BG);
@@ -110,8 +114,43 @@ void UITask::wifiKeyboardHide() {
if (_body) lv_obj_set_height(_body, lv_obj_get_height(_body) + lv_obj_get_height(_wifi_kb));
}
// Settings > CONNECTIVITY > WiFi, as the Bluetooth row: the switch turns it
// on / off, the row opens the network settings.
static void wifiRow(lv_obj_t* body) {
char ssid[33], pass[65], sub[48];
bool have = lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass));
bool on = lvport::wifiAllowed();
if (!on) snprintf(sub, sizeof(sub), "Off");
else if (have) snprintf(sub, sizeof(sub), "%s", ssid);
else snprintf(sub, sizeof(sub), "Tap to pick a network");
lv_obj_t* sw = switchRow(body, LV_SYMBOL_WIFI " WiFi", sub, nullptr);
if (on) lv_obj_add_state(sw, LV_STATE_CHECKED);
lv_obj_add_event_cb(sw, onWifiAllowed, LV_EVENT_VALUE_CHANGED, NULL);
lv_obj_t* row = lv_obj_get_parent(sw);
lv_obj_add_flag(row, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(row, [](lv_event_t* e) {
if (lv_event_get_target(e) == lv_event_get_current_target(e)) s_ui->showWifi(false); // not the switch
}, LV_EVENT_CLICKED, NULL);
}
void UITask::wifiSetAllowed(bool on) {
lvport::setWifiAllowed(on);
if (!on) {
if (mapview::s_dl.active()) mapDownloadStop();
if (_wifi_scanning) { _wifi_scanning = false; lvport::netEnd(); }
}
showToast(on ? "WiFi on - used for map downloads" : "WiFi off", 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); }
}
}
void UITask::wifiScan() {
if (_wifi_scanning) return;
if (!lvport::wifiAllowed()) { lv_label_set_text(_wifi_status, "Turn WiFi on in Settings first."); return; }
lvport::scanStart();
_wifi_scanning = true;
lv_label_set_text(_wifi_status, LV_SYMBOL_REFRESH " Scanning...");
+12 -2
View File
@@ -65,7 +65,8 @@
<h2>Wio Tracker L2 &middot; ui-lvgl <span class="status" id="status-L">booting…</span></h2>
<canvas id="sim-canvas-L" width="320" height="240"></canvas>
<div class="row">
<button id="btn-L">Button (back / sleep)</button>
<button id="btn-L">User (back; hold: mute)</button>
<button id="wake-L">Wake (screen off / on)</button>
<button id="reset-L">Reset L2</button>
</div>
</div>
@@ -272,7 +273,16 @@
async function tap(x, y) { touch(x, y, true); await sleep(80); touch(x, y, false); await sleep(120); }
async function hold(x, y, ms) { touch(x, y, true); await sleep(ms || 700); } // leaves the finger down
document.getElementById('btn-L').addEventListener('click', () => L && L.call('sim_lcd_button', null));
// User button: a click goes back, a 1 s hold mutes (as MomentaryButton's long press).
let btnDownAt = 0;
const btnL = document.getElementById('btn-L');
btnL.addEventListener('pointerdown', () => { btnDownAt = performance.now(); });
btnL.addEventListener('pointerup', () => {
if (!L || !btnDownAt) return;
L.call(performance.now() - btnDownAt >= 1000 ? 'sim_lcd_button_hold' : 'sim_lcd_button', null);
btnDownAt = 0;
});
document.getElementById('wake-L').addEventListener('click', () => L && L.call('sim_wake_button', null));
document.getElementById('reset-L').addEventListener('click', () => bootL());
document.getElementById('advert').addEventListener('click', advertBoth);
document.getElementById('peer-dm').addEventListener('click', () => {
+1 -1
View File
@@ -15,7 +15,7 @@ EnvironmentSensorManager sensors(gps);
#ifdef DISPLAY_CLASS
DISPLAY_CLASS display;
MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true);
MomentaryButton user_btn(PIN_USER_BTN, 1000, true, true, false); // no multi-click: back answers at once
#endif
#ifdef BUZZER_I2S