From 8663d50412708ee307c0e345d99e4e32676c1fc4 Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Fri, 25 Sep 2026 20:51:00 +0200 Subject: [PATCH] feat(ui-core,ui-lvgl): favourites pinning in the Core, L2 node name, reboot, lock screen, favourites dial, 12-hour clock Co-Authored-By: Claude Opus 5.5 --- docs/development/l2-hardware-checklist.md | 11 + docs/development/ui-core.md | 2 +- .../companion_radio/ui-core/ContactControl.h | 19 + examples/companion_radio/ui-core/Favourites.h | 12 + .../companion_radio/ui-core/SettingsSchema.h | 2 + .../companion_radio/ui-lvgl/ChannelScreen.h | 6 +- .../companion_radio/ui-lvgl/ClockScreen.h | 1 + .../ui-lvgl/ConversationScreen.h | 11 +- .../companion_radio/ui-lvgl/DeviceScreen.h | 391 ++++++++++++++++++ examples/companion_radio/ui-lvgl/UITask.cpp | 75 +++- examples/companion_radio/ui-lvgl/UITask.h | 20 +- .../companion_radio/ui-new/MessagesScreen.h | 8 +- 12 files changed, 528 insertions(+), 30 deletions(-) create mode 100644 examples/companion_radio/ui-lvgl/DeviceScreen.h diff --git a/docs/development/l2-hardware-checklist.md b/docs/development/l2-hardware-checklist.md index 91236ef6..7b1d23dd 100644 --- a/docs/development/l2-hardware-checklist.md +++ b/docs/development/l2-hardware-checklist.md @@ -66,6 +66,17 @@ 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: Node name, reboot, lock screen, favourites dial (2026-09-25) +- [ ] Settings > NODE > Name: popup with the current name, Enter saves; Home and Settings show the new name; another node sees it after your next advert +- [ ] Settings > SYSTEM > Reboot / Power off: confirm popup (red button); reboot comes back normally, messages and settings kept +- [ ] Settings > Display & power > Lock screen on; > TIME > 12-hour clock changes status bar ("2:05 PM"), Home and lock screen (AM/PM before the date) +- [ ] With Lock screen on: screen off (timeout or the button on Home) → wake → clock card with "N new messages"; tapping anywhere does nothing; the knob follows the finger; sliding to the right end unlocks; letting go early springs it back +- [ ] Locked: a new message still wakes the screen and shows the toast; the button turns the screen off again; alarm ring shows over the lock +- [ ] In the pocket for a while with Lock screen on: nothing changed / sent +- [ ] Home page 2 > Favourites: 6 slots; tap an empty one → pick a channel / contact / room; filled slot shows # / person / house icon and unread badge; tap opens it (a room logs in first); hold → Change / Remove +- [ ] Channel options and conversation options: pin icon → "Pin to favourites" with six slots (current one highlighted, tap it again to unpin) +- [ ] L1 (ui-new): favourites dial and Pin to dial work as before + ## New: Rooms, conversation options, message actions (2026-09-25) - [ ] Messages: sections CHANNELS / DIRECT / ROOMS; "All" / "★ Fav" pill on CHANNELS and ROOMS shows only favourites (and survives a reboot) - [ ] ROOMS lists room servers ("Tap to log in" / last post); "ROOMS - N new" when posts arrived diff --git a/docs/development/ui-core.md b/docs/development/ui-core.md index 4698a0d3..7d44ef9d 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 three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). 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`. 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. +`ui-core/SettingsSchema.h` holds three pages -- navigation (trail, live share, arrival alert, units), display & power (brightness, screen off, wake on message, battery shutdown, GPS power saving, time zone) and messages & contacts (DM resends, contact expiry, favourites first). 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. Replaces the per-item code in `SettingsScreen`: diff --git a/examples/companion_radio/ui-core/ContactControl.h b/examples/companion_radio/ui-core/ContactControl.h index f05bfd26..14974dcd 100644 --- a/examples/companion_radio/ui-core/ContactControl.h +++ b/examples/companion_radio/ui-core/ContactControl.h @@ -84,4 +84,23 @@ inline void hopName(uint8_t path_len_packed, const uint8_t* path, int i, char* o snprintf(out, n, "?%s", hex); } +// A favourites dial slot's name (ui-core/Favourites.h): contact / room or +// channel name; false when empty or the contact / channel is gone. +// contact_out (optional) gets the contact. +inline bool favName(const NodePrefs* p, int slot, char* out, size_t n, ContactInfo* contact_out = nullptr) { + if (favslots::isEmpty(p, slot)) return false; + const uint8_t* pfx = p->favourite_contacts[slot]; + if (favslots::kind(p, slot) == NodePrefs::FAV_KIND_CHANNEL) { + ChannelDetails ch; + if (!the_mesh.getChannel(pfx[0], ch) || !ch.name[0]) return false; + snprintf(out, n, "%s", ch.name); + return true; + } + ContactInfo* c = the_mesh.lookupContactByPubKey(pfx, NodePrefs::FAVOURITE_PREFIX_LEN); + if (!c) return false; + snprintf(out, n, "%s", c->name); + if (contact_out) *contact_out = *c; + return true; +} + } // namespace contactctl diff --git a/examples/companion_radio/ui-core/Favourites.h b/examples/companion_radio/ui-core/Favourites.h index 2df7b0fa..5eecbcb2 100644 --- a/examples/companion_radio/ui-core/Favourites.h +++ b/examples/companion_radio/ui-core/Favourites.h @@ -58,4 +58,16 @@ inline void clear(NodePrefs* p, int slot) { p->favourite_kinds[slot] = NodePrefs::FAV_KIND_CONTACT; } +// Pin into `slot`, moving it there if it already sits in another one. +inline void pinContact(NodePrefs* p, int slot, const uint8_t* pub_key) { + int existing = findContact(p, pub_key); + if (existing >= 0 && existing != slot) clear(p, existing); + setContact(p, slot, pub_key); +} +inline void pinChannel(NodePrefs* p, int slot, uint8_t ch_idx) { + int existing = findChannel(p, ch_idx); + if (existing >= 0 && existing != slot) clear(p, existing); + setChannel(p, slot, ch_idx); +} + } // namespace favslots diff --git a/examples/companion_radio/ui-core/SettingsSchema.h b/examples/companion_radio/ui-core/SettingsSchema.h index df31b20c..fff1137d 100644 --- a/examples/companion_radio/ui-core/SettingsSchema.h +++ b/examples/companion_radio/ui-core/SettingsSchema.h @@ -165,11 +165,13 @@ static const Setting ALL[] = { 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, nullptr, nullptr), + SW("Lock screen", "Slide to unlock after the screen turns off", SEC_DISPLAY, auto_lock, nullptr), MAP("Battery shutdown", "Power off below this voltage", SEC_POWER, low_batt_mv, LOW_BATT, optLowBatt, nullptr), MAP("GPS power saving", "Sleep between fixes", SEC_POWER, gps_interval, GPS_DUTY, optGpsDuty, applyGpsDuty), 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), IDX("Resend direct messages", "Extra tries without a delivery tick", SEC_MESSAGES, dm_resend_count, 6, optResend, nullptr), diff --git a/examples/companion_radio/ui-lvgl/ChannelScreen.h b/examples/companion_radio/ui-lvgl/ChannelScreen.h index 6d054b40..8ba6057b 100644 --- a/examples/companion_radio/ui-lvgl/ChannelScreen.h +++ b/examples/companion_radio/ui-lvgl/ChannelScreen.h @@ -10,7 +10,7 @@ namespace chanview { -enum : uint8_t { A_FAV, A_EDIT, A_DELETE, A_READ }; +enum : uint8_t { A_FAV, A_EDIT, A_DELETE, A_READ, A_PIN }; enum : uint8_t { T_PUBLIC, T_HASHTAG, T_PRIVATE }; enum : uint8_t { C_NOTIF, C_SCOPE }; @@ -120,6 +120,7 @@ void UITask::channelMenu(int idx) { struct { const char* text; uint8_t act; } btns[] = { { UI_SYMBOL_STAR " Fav", A_FAV }, { LV_SYMBOL_OK " Read", A_READ }, + { UI_SYMBOL_PIN, A_PIN }, { LV_SYMBOL_EDIT " Edit", A_EDIT }, { LV_SYMBOL_TRASH, A_DELETE }, }; @@ -173,6 +174,9 @@ void UITask::channelAction(uint8_t act) { navClosePopup(); showChannelEdit(idx); break; + case A_PIN: + pinPopup(true, (uint8_t)idx, nullptr); // DeviceScreen.h + break; case A_DELETE: if (!s_del_armed_ms || millis() - s_del_armed_ms > 3000) { // second tap within 3 s confirms s_del_armed_ms = millis() | 1; diff --git a/examples/companion_radio/ui-lvgl/ClockScreen.h b/examples/companion_radio/ui-lvgl/ClockScreen.h index 74cdf94f..82d4102c 100644 --- a/examples/companion_radio/ui-lvgl/ClockScreen.h +++ b/examples/companion_radio/ui-lvgl/ClockScreen.h @@ -295,6 +295,7 @@ void UITask::showRing(const char* text) { } lv_label_set_text(s_ring_lbl, text); lv_obj_remove_flag(s_ring, LV_OBJ_FLAG_HIDDEN); + lv_obj_move_foreground(s_ring); // over the lock screen too } void UITask::dismissRing() { diff --git a/examples/companion_radio/ui-lvgl/ConversationScreen.h b/examples/companion_radio/ui-lvgl/ConversationScreen.h index e26b452e..33b557d2 100644 --- a/examples/companion_radio/ui-lvgl/ConversationScreen.h +++ b/examples/companion_radio/ui-lvgl/ConversationScreen.h @@ -16,7 +16,7 @@ namespace convview { -enum : uint8_t { A_FAV, A_READ, A_LOGIN, A_LOGOUT }; +enum : uint8_t { A_FAV, A_READ, A_LOGIN, A_LOGOUT, A_PIN }; enum : uint8_t { M_REPLY, M_TARGET }; static uint8_t s_key[PUB_KEY_SIZE]; // the conversation the popup is about @@ -157,13 +157,14 @@ void UITask::roomPoll() { if (o == RoomSessions::NONE) return; bool mine = s_login_wait && memcmp(key, s_login_key, 4) == 0; if (mine) s_login_wait = false; + bool picking = (_screen == SCR_CHATS || _screen == SCR_FAVS) && !_nav_overlay && !locked(); // where it was tapped if (o == RoomSessions::LOGGED_IN) { showToast("Logged in", 1200); - if (mine && _screen == SCR_CHATS && !_nav_overlay) openDM(s_login_key); + if (mine && picking) openDM(s_login_key); else if (_screen == SCR_CHATS && !_nav_overlay) buildChats(); } else if (o == RoomSessions::LOGIN_FAILED) { showToast("Login failed - wrong password?", 3000); - if (mine && _screen == SCR_CHATS && !_nav_overlay) roomLoginPopup(s_login_key); + if (mine && picking) roomLoginPopup(s_login_key); } else { showToast("No answer from the room", 3000); } @@ -193,6 +194,7 @@ void UITask::conversationMenu(const uint8_t* pub_key) { lv_obj_t* acts = actionRow(panel); s_fav_btn = actionButton(acts, UI_SYMBOL_STAR " Fav", onConvAction, A_FAV, contactctl::favourite(ci)); if (!room && _core->dmUnread(ci.id.pub_key) > 0) actionButton(acts, LV_SYMBOL_OK " Read", onConvAction, A_READ, false); + actionButton(acts, UI_SYMBOL_PIN, onConvAction, A_PIN, favslots::findContact(_prefs, ci.id.pub_key) >= 0); if (room) { actionButton(acts, LV_SYMBOL_EDIT " Login", onConvAction, A_LOGIN, false); if (_core->rooms.isLoggedIn(ci.id.pub_key)) actionButton(acts, LV_SYMBOL_CLOSE " Logout", onConvAction, A_LOGOUT, false); @@ -226,6 +228,9 @@ void UITask::conversationAction(uint8_t act) { case A_LOGIN: roomLoginPopup(ci.id.pub_key); break; + case A_PIN: + pinPopup(false, 0, ci.id.pub_key); // DeviceScreen.h + break; case A_LOGOUT: _core->rooms.logout(ci.id.pub_key); navClosePopup(); diff --git a/examples/companion_radio/ui-lvgl/DeviceScreen.h b/examples/companion_radio/ui-lvgl/DeviceScreen.h new file mode 100644 index 00000000..803a352f --- /dev/null +++ b/examples/companion_radio/ui-lvgl/DeviceScreen.h @@ -0,0 +1,391 @@ +#pragma once +// Device-level pieces from ui-new's Settings > System and Home: +// - node name (Settings > Node), reboot / power off (Settings > System); +// - lock screen (Settings > Display & power > Lock screen, NodePrefs::auto_lock): +// once the screen turns off, waking shows a clock card and nothing reacts +// until "slide to unlock" -- against touches in a pocket; +// - favourites dial (Home > Favourites, NodePrefs::favourite_contacts): six +// slots holding a contact, room or channel; tap opens it, hold changes / +// removes it; "Pin" in a conversation's / channel's options puts it on one. +// Slot storage is ui-core/Favourites.h, names contactctl::favName, shared +// with ui-new's dial. +// +// Single-TU fragment: included at the end of ui-lvgl/UITask.cpp. + +namespace devview { + +static lv_obj_t* s_lock = nullptr; // lock overlay (lv_layer_top) +static lv_obj_t* s_lock_clock = nullptr; +static lv_obj_t* s_lock_date = nullptr; +static lv_obj_t* s_lock_unread = nullptr; +static lv_obj_t* s_lock_slider = nullptr; + +static int s_fav_slot = -1; // slot the hold / pick popup is about +static uint8_t s_pick_keys[64][PUB_KEY_SIZE]; +enum : int { PICK_CONTACT = 100 }; // pick codes: channel index, or PICK_CONTACT + row + +// "Pin" from an options popup: what gets pinned. +static bool s_pin_channel = false; +static uint8_t s_pin_ch = 0; +static uint8_t s_pin_key[PUB_KEY_SIZE]; + +enum : uint8_t { F_CHANGE, F_REMOVE }; + +// Text field whose limit is in UTF-8 bytes (user data: the byte cap). +static void onByteCapInsert(lv_event_t* e) { + lv_obj_t* ta = (lv_obj_t*)lv_event_get_target(e); + const char* ins = (const char*)lv_event_get_param(e); + size_t cap = (size_t)(uintptr_t)lv_event_get_user_data(e); + if (ins && strlen(lv_textarea_get_text(ta)) + strlen(ins) > cap) lv_textarea_set_insert_replace(ta, ""); +} + +} // namespace devview + +static void onNodeName(lv_event_t* e) { (void)e; s_ui->nodeNamePopup(); } +static void onNodeNameKb(lv_event_t* e) { s_ui->nodeNameDone(lv_event_get_code(e) == LV_EVENT_READY); } +static void onPowerRow(lv_event_t* e) { s_ui->powerPopup((uintptr_t)lv_event_get_user_data(e) != 0); } +static void onPowerGo(lv_event_t* e) { s_ui->shutdown((uintptr_t)lv_event_get_user_data(e) != 0); } +static void onOpenFavourites(lv_event_t* e) { (void)e; s_ui->showFavourites(); } +static void onFavTap(lv_event_t* e) { s_ui->favTap((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onFavHold(lv_event_t* e) { + lv_indev_wait_release(lv_indev_active()); // the hold isn't also a tap that opens it + s_ui->favHold((int)(uintptr_t)lv_event_get_user_data(e)); +} +static void onFavAction(lv_event_t* e) { s_ui->favAction((uint8_t)(uintptr_t)lv_event_get_user_data(e)); } +static void onFavPick(lv_event_t* e) { s_ui->favPick((int)(uintptr_t)lv_event_get_user_data(e)); } +static void onPinSlot(lv_event_t* e) { s_ui->pinTo((int)(uintptr_t)lv_event_get_user_data(e)); } + +// ── Node name, reboot, power off ────────────────────────────────────────────── + +void UITask::nodeNamePopup() { + if (!_prefs) return; + lv_obj_t* panel = navPopupPanel("Node name", false); + lv_obj_align(panel, LV_ALIGN_TOP_MID, 0, theme::STATUS_H + 4); // above the keyboard + _nav_ta = wifiField(panel, "Shown to others in adverts", false); + lv_obj_remove_event_cb(_nav_ta, onWifiField); + lv_textarea_set_text(_nav_ta, the_mesh.getNodeName()); + lv_obj_add_event_cb(_nav_ta, devview::onByteCapInsert, LV_EVENT_INSERT, + (void*)(uintptr_t)(sizeof(_prefs->node_name) - 1)); + lv_obj_add_state(_nav_ta, LV_STATE_FOCUSED); // draws the cursor + _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, onNodeNameKb, LV_EVENT_READY, NULL); + lv_obj_add_event_cb(_nav_kb, onNodeNameKb, LV_EVENT_CANCEL, NULL); +} + +void UITask::nodeNameDone(bool ok) { + if (ok && _nav_ta && _prefs) { + const char* t = lv_textarea_get_text(_nav_ta); + if (!t[0]) { showToast("Name can't be empty"); return; } + snprintf(_prefs->node_name, sizeof(_prefs->node_name), "%s", t); + the_mesh.savePrefs(); // getNodeName() and the self-advert read node_name live + showToast("Name saved - others see it with your next advert", 3000); + } + navClosePopup(); + if (ok && _screen == SCR_SETTINGS) buildSettings(); +} + +void UITask::powerPopup(bool restart) { + lv_obj_t* panel = navPopupPanel(restart ? "Reboot?" : "Power off?", false); + lv_obj_t* t = label(panel, restart ? "Restarts the device. Messages and settings are kept." + : "Turns the device off. Messages and settings are kept; no messages arrive while it is off.", + THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_label_set_long_mode(t, LV_LABEL_LONG_WRAP); + lv_obj_set_width(t, LV_PCT(100)); + lv_obj_t* b = lv_button_create(panel); + lv_obj_set_size(b, LV_PCT(100), 40); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::FAIL), 0); + lv_obj_add_event_cb(b, onPowerGo, LV_EVENT_CLICKED, (void*)(uintptr_t)(restart ? 1 : 0)); + lv_obj_center(label(b, restart ? LV_SYMBOL_REFRESH " Reboot" : LV_SYMBOL_POWER " Power off", THEME_FONT_BODY, theme::TEXT)); +} + +// ── Lock screen ─────────────────────────────────────────────────────────────── + +// From sleep() when the lock is on. Built while the screen is dark, so waking +// shows it straight away. +void UITask::lockScreen() { + using namespace devview; + if (s_lock) return; + navClosePopup(); + setKeyboardVisible(false); + s_lock = lv_obj_create(lv_layer_top()); + lv_obj_remove_style_all(s_lock); + lv_obj_set_size(s_lock, LV_PCT(100), lv_display_get_vertical_resolution(NULL) - theme::STATUS_H); + lv_obj_set_pos(s_lock, 0, theme::STATUS_H); // the status bar stays visible + lv_obj_set_style_bg_color(s_lock, lv_color_hex(theme::BG), 0); + lv_obj_set_style_bg_opa(s_lock, LV_OPA_COVER, 0); + lv_obj_add_flag(s_lock, LV_OBJ_FLAG_CLICKABLE); // swallows every touch but the slider's + lv_obj_remove_flag(s_lock, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_flex_flow(s_lock, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(s_lock, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_style_pad_top(s_lock, 18, 0); + lv_obj_set_style_pad_row(s_lock, 4, 0); + s_lock_clock = label(s_lock, "--:--", THEME_FONT_CLOCK, theme::TEXT); + s_lock_date = label(s_lock, "", THEME_FONT_BODY, theme::TEXT_MUTED); + s_lock_unread = label(s_lock, "", THEME_FONT_BODY, theme::ACCENT); + lv_obj_set_style_pad_top(s_lock_unread, 8, 0); + + lv_obj_t* track = lv_obj_create(s_lock); // grows to push the slider to the bottom + lv_obj_remove_style_all(track); + lv_obj_set_width(track, 1); + lv_obj_set_flex_grow(track, 1); + + s_lock_slider = lv_slider_create(s_lock); + lv_obj_set_size(s_lock_slider, 250, 44); + lv_obj_set_style_margin_bottom(s_lock_slider, 44, 0); // clear of the toast below + lv_slider_set_range(s_lock_slider, 0, 100); + lv_obj_set_style_anim_duration(s_lock_slider, 0, 0); // no lag behind the finger + lv_obj_set_style_bg_color(s_lock_slider, lv_color_hex(theme::SURFACE), LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_lock_slider, LV_OPA_COVER, LV_PART_MAIN); + lv_obj_set_style_radius(s_lock_slider, 22, LV_PART_MAIN); + lv_obj_set_style_bg_opa(s_lock_slider, LV_OPA_TRANSP, LV_PART_INDICATOR); + lv_obj_set_style_bg_color(s_lock_slider, lv_color_hex(theme::ACCENT), LV_PART_KNOB); + lv_obj_set_style_pad_all(s_lock_slider, 2, LV_PART_KNOB); + lv_obj_set_style_shadow_width(s_lock_slider, 0, LV_PART_KNOB); + lv_obj_remove_flag(s_lock_slider, LV_OBJ_FLAG_CLICKABLE); // lockPoll() drives it + lv_obj_t* hint = label(s_lock_slider, "slide to unlock " LV_SYMBOL_RIGHT, THEME_FONT_BODY, theme::TEXT_MUTED); + lv_obj_align(hint, LV_ALIGN_CENTER, 14, 0); + + if (_toast) lv_obj_move_foreground(_toast); // new-message toasts still show + refreshLock(); +} + +void UITask::unlockScreen() { + using namespace devview; + if (!s_lock) return; + lv_indev_wait_release(lv_indev_active()); // the slide isn't also a tap underneath + lv_obj_delete_async(s_lock); + s_lock = s_lock_clock = s_lock_date = s_lock_unread = s_lock_slider = nullptr; + if (_screen == SCR_HOME) refreshHome(); + else if (_screen == SCR_CHATS) buildChats(); // counts moved on while locked +} + +bool UITask::locked() const { return devview::s_lock != nullptr; } + +// From loop() while locked: the knob follows the finger. Tracked from the +// touch itself, like the Home page swipe -- LVGL stops feeding a pressed +// widget once the finger has moved past its scroll threshold. +void UITask::lockPoll() { + using namespace devview; + static bool s_dragging = false, s_was_down = false; + lv_indev_t* in = lv_indev_get_next(NULL); + if (!s_lock_slider || !in) { s_dragging = s_was_down = false; return; } + bool down = lv_indev_get_state(in) == LV_INDEV_STATE_PRESSED; + lv_point_t p; + lv_indev_get_point(in, &p); + lv_area_t a; + lv_obj_get_coords(s_lock_slider, &a); + int32_t knob = lv_area_get_height(&a); // round knob, as tall as the track + if (down && !s_was_down) { // a drag starts on the knob only + s_dragging = p.y >= a.y1 - 10 && p.y <= a.y2 + 10 && p.x <= a.x1 + knob + 16; + } + s_was_down = down; + if (!down) { + if (s_dragging || lv_slider_get_value(s_lock_slider) != 0) lv_slider_set_value(s_lock_slider, 0, LV_ANIM_OFF); + s_dragging = false; + return; + } + if (!s_dragging) return; + int32_t span = lv_area_get_width(&a) - knob; + int32_t v = span > 0 ? (p.x - a.x1 - knob / 2) * 100 / span : 0; + v = v < 0 ? 0 : v > 100 ? 100 : v; + lv_slider_set_value(s_lock_slider, v, LV_ANIM_OFF); + if (v >= 95) { s_dragging = s_was_down = false; unlockScreen(); } +} + +void UITask::refreshLock() { + using namespace devview; + if (!s_lock) return; + struct tm ti; + if (localTime(_prefs, ti)) { + char clk[12], date[48]; + fmtClock(clk, sizeof(clk), ti, _prefs, false); + fmtDate(date, sizeof(date), ti, _prefs); + lv_label_set_text(s_lock_clock, clk); + lv_label_set_text(s_lock_date, date); + } + int unread = _core->dmUnreadTotal() + _core->history.getTotalChannelUnread() + _core->roomUnread(); + if (unread > 0) lv_label_set_text_fmt(s_lock_unread, LV_SYMBOL_ENVELOPE " %d new message%s", unread, unread == 1 ? "" : "s"); + else lv_label_set_text(s_lock_unread, ""); +} + +// ── Favourites dial ─────────────────────────────────────────────────────────── + +void UITask::showFavourites() { + _screen = SCR_FAVS; + buildFavourites(); +} + +void UITask::buildFavourites() { + lv_obj_t* body = newScreen("Favourites", true); + lv_obj_t* grid = lv_obj_create(body); + styleSurface(grid, theme::BG); + lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(grid, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_style_pad_column(grid, theme::GAP, 0); + lv_obj_set_style_pad_row(grid, theme::GAP, 0); + int w = (lv_display_get_horizontal_resolution(NULL) - 2 * theme::PAD - 2 * theme::GAP) / 3; + for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) { + char name[33]; + ContactInfo c; + bool used = contactctl::favName(_prefs, s, name, sizeof(name), &c); + bool chan = favslots::kind(_prefs, s) == NodePrefs::FAV_KIND_CHANNEL; + lv_obj_t* b = lv_button_create(grid); + lv_obj_set_size(b, w, 70); + lv_obj_set_style_pad_all(b, 4, 0); + lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(used ? theme::SURFACE : theme::BG), 0); + lv_obj_set_style_bg_color(b, lv_color_hex(theme::SURFACE_2), LV_STATE_PRESSED); + if (!used) { + lv_obj_set_style_border_color(b, lv_color_hex(theme::SURFACE_2), 0); + lv_obj_set_style_border_width(b, 1, 0); + } + lv_obj_add_event_cb(b, onFavTap, LV_EVENT_CLICKED, (void*)(uintptr_t)s); + lv_obj_add_event_cb(b, onFavHold, LV_EVENT_LONG_PRESSED, (void*)(uintptr_t)s); + const char* icon = !used ? LV_SYMBOL_PLUS : chan ? "#" : c.type == ADV_TYPE_ROOM ? LV_SYMBOL_HOME : UI_SYMBOL_USERS; + lv_obj_align(label(b, icon, THEME_FONT_LARGE, used ? theme::ACCENT : theme::TEXT_MUTED), LV_ALIGN_TOP_MID, 0, 8); + lv_obj_t* n = label(b, used ? name : "Add", THEME_FONT_SMALL, used ? theme::TEXT : theme::TEXT_MUTED); + lv_label_set_long_mode(n, LV_LABEL_LONG_DOT); + lv_obj_set_style_text_align(n, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_width(n, w - 10); + lv_obj_align(n, LV_ALIGN_BOTTOM_MID, 0, -6); + if (used) { + int unread = chan ? _core->history.chUnread(_prefs->favourite_contacts[s][0]) + : (c.type == ADV_TYPE_CHAT ? _core->dmUnread(c.id.pub_key) : 0); + badge(b, unread, false); + } + } + label(body, "Tap to open. Hold to change or remove.", THEME_FONT_SMALL, theme::TEXT_MUTED); +} + +void UITask::favTap(int slot) { + char name[33]; + ContactInfo c; + if (!contactctl::favName(_prefs, slot, name, sizeof(name), &c)) { favPickPopup(slot); return; } + if (favslots::kind(_prefs, slot) == NodePrefs::FAV_KIND_CHANNEL) openChannel(_prefs->favourite_contacts[slot][0]); + else if (c.type == ADV_TYPE_ROOM) openRoom(c.id.pub_key); + else openDM(c.id.pub_key); +} + +void UITask::favHold(int slot) { + char name[33]; + if (!contactctl::favName(_prefs, slot, name, sizeof(name))) { favPickPopup(slot); return; } + devview::s_fav_slot = slot; + lv_obj_t* panel = navPopupPanel(name, false); + lv_obj_t* acts = convview::actionRow(panel); + convview::actionButton(acts, LV_SYMBOL_EDIT " Change", onFavAction, devview::F_CHANGE, false); + convview::actionButton(acts, LV_SYMBOL_TRASH " Remove", onFavAction, devview::F_REMOVE, false); +} + +void UITask::favAction(uint8_t act) { + int slot = devview::s_fav_slot; + if (act == devview::F_CHANGE) { favPickPopup(slot); return; } + favslots::clear(_prefs, slot); + the_mesh.savePrefs(); + navClosePopup(); + buildFavourites(); +} + +// What to put in a slot: channels, then contacts and rooms (favourites first). +void UITask::favPickPopup(int slot) { + using namespace devview; + s_fav_slot = slot; + char title[24]; + snprintf(title, sizeof(title), "Slot %d", slot + 1); + lv_obj_t* panel = navPopupPanel(title, true); + lv_obj_t* list = lv_obj_create(panel); + styleSurface(list, theme::BG); + lv_obj_set_width(list, LV_PCT(100)); + lv_obj_set_flex_grow(list, 1); + lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_row(list, theme::GAP, 0); + sectionTitle(list, "CHANNELS"); + for (int i = 0; i < MAX_GROUP_CHANNELS; i++) { + ChannelDetails ch; + if (!the_mesh.getChannel(i, ch) || !ch.name[0]) continue; + listRow(list, ch.name, NULL, onFavPick, (void*)(uintptr_t)i); + } + sectionTitle(list, "CONTACTS AND ROOMS"); + int rows = 0, total = the_mesh.getNumContacts(); + const int MAX_ROWS = (int)(sizeof(s_pick_keys) / sizeof(s_pick_keys[0])); + for (int pass = 0; pass < 2; pass++) { + for (int i = 0; i < total && rows < MAX_ROWS; i++) { + ContactInfo c; + if (!the_mesh.getContactByIdx(MAX_ANON_CONTACTS + i, c)) continue; + if (c.type != ADV_TYPE_CHAT && c.type != ADV_TYPE_ROOM) continue; + if (contactctl::favourite(c) != (pass == 0)) continue; + memcpy(s_pick_keys[rows], c.id.pub_key, PUB_KEY_SIZE); + char t[48]; + snprintf(t, sizeof(t), "%s%s%s", contactctl::favourite(c) ? UI_SYMBOL_STAR " " : "", c.name, + c.type == ADV_TYPE_ROOM ? " (room)" : ""); + listRow(list, t, NULL, onFavPick, (void*)(uintptr_t)(PICK_CONTACT + rows)); + rows++; + } + } +} + +void UITask::favPick(int code) { + using namespace devview; + if (code >= PICK_CONTACT) favslots::pinContact(_prefs, s_fav_slot, s_pick_keys[code - PICK_CONTACT]); + else favslots::pinChannel(_prefs, s_fav_slot, (uint8_t)code); + the_mesh.savePrefs(); + navClosePopup(); + if (_screen == SCR_FAVS) buildFavourites(); +} + +// "Pin" in an options popup: six slot buttons with who is in them now. +void UITask::pinPopup(bool channel, uint8_t ch_idx, const uint8_t* pub_key) { + using namespace devview; + s_pin_channel = channel; + s_pin_ch = ch_idx; + if (pub_key) memcpy(s_pin_key, pub_key, PUB_KEY_SIZE); + lv_obj_t* panel = navPopupPanel("Pin to favourites", false); + lv_obj_t* grid = lv_obj_create(panel); + styleSurface(grid, theme::BG); + lv_obj_remove_flag(grid, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_size(grid, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP); + lv_obj_set_style_pad_column(grid, theme::GAP, 0); + lv_obj_set_style_pad_row(grid, theme::GAP, 0); + int here = channel ? favslots::findChannel(_prefs, ch_idx) : favslots::findContact(_prefs, pub_key); + for (int s = 0; s < NodePrefs::FAVOURITES_COUNT; s++) { + char name[33], t[48]; + bool used = contactctl::favName(_prefs, s, name, sizeof(name)); + snprintf(t, sizeof(t), "%d %s", s + 1, used ? name : "empty"); + lv_obj_t* b = lv_button_create(grid); + lv_obj_set_size(b, 90, 36); + lv_obj_set_style_pad_hor(b, 4, 0); + lv_obj_set_style_radius(b, theme::RADIUS, 0); + lv_obj_set_style_shadow_width(b, 0, 0); + lv_obj_set_style_bg_color(b, lv_color_hex(s == here ? theme::ACCENT_DIM : theme::SURFACE), 0); + lv_obj_add_event_cb(b, onPinSlot, LV_EVENT_CLICKED, (void*)(uintptr_t)s); + lv_obj_t* l = label(b, t, THEME_FONT_SMALL, used ? theme::TEXT : theme::TEXT_MUTED); + lv_label_set_long_mode(l, LV_LABEL_LONG_DOT); + lv_obj_set_width(l, 82); + lv_obj_center(l); + } + label(panel, here >= 0 ? "Tap its slot again to unpin." : "A taken slot is replaced.", THEME_FONT_SMALL, theme::TEXT_MUTED); +} + +void UITask::pinTo(int slot) { + using namespace devview; + int here = s_pin_channel ? favslots::findChannel(_prefs, s_pin_ch) : favslots::findContact(_prefs, s_pin_key); + char msg[32]; + if (here == slot) { + favslots::clear(_prefs, slot); + snprintf(msg, sizeof(msg), "Unpinned"); + } else { + if (s_pin_channel) favslots::pinChannel(_prefs, slot, s_pin_ch); + else favslots::pinContact(_prefs, slot, s_pin_key); + snprintf(msg, sizeof(msg), "Pinned to slot %d", slot + 1); + } + the_mesh.savePrefs(); + navClosePopup(); + showToast(msg, 1500); +} diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 588180c4..4078b5f4 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -79,6 +79,33 @@ static void formatAge(char* buf, size_t n, uint32_t ts) { else snprintf(buf, n, "%lud", (unsigned long)(d / 86400)); } +// Local time (NodePrefs::tz_offset_hours); false before the clock is set. +static bool localTime(const NodePrefs* p, struct tm& out) { + uint32_t now = rtc_clock.getCurrentTime(); + if (now <= 1000000000UL) return false; + time_t t = (time_t)((int64_t)now + (int64_t)(p ? p->tz_offset_hours : 0) * 3600); + out = *gmtime(&t); + return true; +} +// "14:05", or "2:05" (+ " PM" with suffix) with Settings > 12-hour clock. +static void fmtClock(char* b, size_t n, const struct tm& ti, const NodePrefs* p, bool suffix) { + if (p && p->clock_12h) { + int h = ti.tm_hour % 12; + snprintf(b, n, "%d:%02d%s", h ? h : 12, ti.tm_min, suffix ? (ti.tm_hour < 12 ? " AM" : " PM") : ""); + } else { + snprintf(b, n, "%02d:%02d", ti.tm_hour, ti.tm_min); + } +} +// "Thu 25 Sep 2026" ("PM Thu 25 Sep 2026" on a 12-hour clock, whose big +// digits have no room for it). +static void fmtDate(char* b, size_t n, const struct tm& ti, const NodePrefs* p) { + static const char* DOW[] = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; + static const char* MON[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; + snprintf(b, n, "%s%s %d %s %d", (p && p->clock_12h) ? (ti.tm_hour < 12 ? "AM " : "PM ") : "", + DOW[ti.tm_wday], ti.tm_mday, MON[ti.tm_mon], ti.tm_year + 1900); +} + static void contactName(const uint8_t* prefix, char* out, size_t n) { ContactInfo c; if (MessageHistory::contactByPrefix(prefix, c) && c.name[0]) snprintf(out, n, "%s", c.name); @@ -136,7 +163,7 @@ void UITask::loop() { if (btn_click) { if (_core->clock.isRinging()) dismissRing(); else if (_asleep) wake(); - else if (_screen == SCR_HOME) sleep(); + else if (_screen == SCR_HOME || locked()) sleep(); else back(); } @@ -160,9 +187,11 @@ void UITask::loop() { if ((int32_t)(millis() - _next_status_ms) >= 0) { _next_status_ms = millis() + 1000; refreshStatusBar(); + refreshLock(); if (_screen == SCR_HOME) refreshHome(); } - if (_screen == SCR_HOME) homeSwipePoll(); + if (locked()) lockPoll(); + else if (_screen == SCR_HOME) homeSwipePoll(); if ((_screen == SCR_NEARBY || _screen == SCR_NODE) && (int32_t)(millis() - _next_nearby_ms) >= 0) { // Scan results trickle in over a few seconds: poll fast while scanning. _next_nearby_ms = millis() + (_scanning ? 250 : 2000); @@ -234,6 +263,7 @@ void UITask::sleep() { if (_asleep) return; _asleep = true; if (_display) _display->turnOff(); + if (_prefs && _prefs->auto_lock) lockScreen(); // Settings > Display & power > Lock screen } void UITask::wake() { @@ -333,11 +363,11 @@ void UITask::buildStatusBar() { void UITask::refreshStatusBar() { if (!_status_time) return; - uint32_t now = rtc_clock.getCurrentTime(); - if (now > 1000000000UL) { - time_t t = (time_t)((int64_t)now + (int64_t)(_prefs ? _prefs->tz_offset_hours : 0) * 3600); - struct tm* ti = gmtime(&t); - lv_label_set_text_fmt(_status_time, "%02d:%02d", ti->tm_hour, ti->tm_min); + struct tm ti; + if (localTime(_prefs, ti)) { + char clk[12]; + fmtClock(clk, sizeof(clk), ti, _prefs, true); + lv_label_set_text(_status_time, clk); } else { lv_label_set_text(_status_time, "--:--"); } @@ -461,8 +491,9 @@ void UITask::back() { case SCR_CONTACTS: showChats(); break; case SCR_CHANNEL_EDIT: showChats(); break; case SCR_BOT: if (_nav_overlay) navClosePopup(); else showHome(); break; + case SCR_FAVS: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_ADMIN: if (_nav_overlay) navClosePopup(); else adminLeave(); break; - case SCR_SETTINGS: showHome(); break; + case SCR_SETTINGS: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_SETTINGS_NAV: showSettings(); break; case SCR_CLOCK: showHome(); break; case SCR_RADIO: if (radioPopupOpen()) radioCloseFreq(); else showSettings(); break; @@ -498,6 +529,9 @@ static void onOpenNodesMap(lv_event_t* e) { (void)e; s_ui->openMap(false); } static void onOpenNav(lv_event_t* e) { (void)e; s_ui->openMap(true); } static void onOpenClock(lv_event_t* e); // ClockScreen.h static void onOpenBot(lv_event_t* e); // BotScreen.h +static void onOpenFavourites(lv_event_t* e); // DeviceScreen.h +static void onNodeName(lv_event_t* e); +static void onPowerRow(lv_event_t* e); // Home apps, in order; PER_PAGE to a page, further pages are swiped to (with // dots underneath), so new apps don't squeeze the row. @@ -509,6 +543,7 @@ static const App APPS[] = { { UI_SYMBOL_MAP, "Map", onOpenNav, false }, { LV_SYMBOL_SETTINGS, "Settings", onOpenSettings, false }, // Page 2: tools + { UI_SYMBOL_STAR, "Favourites", onOpenFavourites, false }, { UI_SYMBOL_CLOCK, "Clock", onOpenClock, false }, { LV_SYMBOL_CHARGE, "Bot", onOpenBot, false }, }; @@ -637,16 +672,13 @@ void UITask::setHomePage(int page) { void UITask::refreshHome() { if (!_home_clock) return; - uint32_t now = rtc_clock.getCurrentTime(); - if (now > 1000000000UL) { - time_t t = (time_t)((int64_t)now + (int64_t)(_prefs ? _prefs->tz_offset_hours : 0) * 3600); - struct tm* ti = gmtime(&t); - static const char* DOW[] = { "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat" }; - static const char* MON[] = { "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; - lv_label_set_text_fmt(_home_clock, "%02d:%02d", ti->tm_hour, ti->tm_min); - lv_label_set_text_fmt(_home_date, "%s %d %s %d", DOW[ti->tm_wday], ti->tm_mday, MON[ti->tm_mon], - ti->tm_year + 1900); + struct tm ti; + if (localTime(_prefs, ti)) { + char clk[12], date[48]; + fmtClock(clk, sizeof(clk), ti, _prefs, false); + fmtDate(date, sizeof(date), ti, _prefs); + lv_label_set_text(_home_clock, clk); + lv_label_set_text(_home_date, date); } else { lv_label_set_text(_home_clock, "--:--"); lv_label_set_text(_home_date, "time not synced"); @@ -1954,6 +1986,8 @@ static void onOpenRadio(lv_event_t* e); // RadioScreen.h void UITask::buildSettings() { lv_obj_t* body = newScreen("Settings", true); + sectionTitle(body, "NODE"); + listRow(body, LV_SYMBOL_EDIT " Name", the_mesh.getNodeName(), onNodeName, NULL); sectionTitle(body, "CONNECTIVITY"); char ssid[33], pass[65]; bool have = lvport::loadWifi(ssid, sizeof(ssid), pass, sizeof(pass)); @@ -1993,6 +2027,10 @@ void UITask::buildSettings() { alt_a == main_a ? 0 : alt_a + 1); label(body, "Hold a letter for accents and other variants.", THEME_FONT_SMALL, theme::TEXT_MUTED); + sectionTitle(body, "SYSTEM"); + 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); + sectionTitle(body, "ABOUT"); char about[200], built[24] = ""; if (!strstr(FIRMWARE_VERSION, FIRMWARE_BUILD_DATE)) snprintf(built, sizeof(built), " (%s)", FIRMWARE_BUILD_DATE); @@ -2012,3 +2050,4 @@ void UITask::setKeyboardAlphabets(int main_idx, int alt_sel) { } #include "ConversationScreen.h" +#include "DeviceScreen.h" diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index cbb437dd..000d5987 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -146,6 +146,19 @@ public: void messageMenu(int idx); void messageAction(uint8_t act); void toggleChatFilter(uint8_t which); + // DeviceScreen.h + void nodeNamePopup(); + void nodeNameDone(bool ok); + void powerPopup(bool restart); + void unlockScreen(); + bool locked() const; + void showFavourites(); + void favTap(int slot); + void favHold(int slot); + void favAction(uint8_t act); + void favPick(int code); + void pinPopup(bool channel, uint8_t ch_idx, const uint8_t* pub_key); + void pinTo(int slot); void setGps(bool on); bool ensureGps(); // true with a fix; else turns GPS on / says it's waiting @@ -154,7 +167,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 }; + 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 }; void buildStatusBar(); void refreshStatusBar(); @@ -215,6 +228,11 @@ private: void adminLeave(); void adminPoll(); void roomPoll(); + void lockScreen(); + void refreshLock(); + void lockPoll(); + void buildFavourites(); + void favPickPopup(int slot); void adminValuePopup(); void adminTextPopup(const char* text, bool digits); void adminReplyPopup(const char* text); diff --git a/examples/companion_radio/ui-new/MessagesScreen.h b/examples/companion_radio/ui-new/MessagesScreen.h index bb1e3b0e..0284e10a 100644 --- a/examples/companion_radio/ui-new/MessagesScreen.h +++ b/examples/companion_radio/ui-new/MessagesScreen.h @@ -639,9 +639,7 @@ class MessagesScreen : public UIScreen { void pinContactToSlot(const ContactInfo& ci, int slot) { if (slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return; - int existing = _task->findFavouriteSlot(ci.id.pub_key); - if (existing >= 0 && existing != slot) _task->clearFavouriteSlot(existing); - _task->setFavouriteSlot(slot, ci.id.pub_key); + favslots::pinContact(_task->getNodePrefs(), slot, ci.id.pub_key); the_mesh.savePrefs(); char alert[24]; snprintf(alert, sizeof(alert), "Pinned to slot %d", slot + 1); @@ -669,9 +667,7 @@ class MessagesScreen : public UIScreen { void pinChannelToSlot(uint8_t ch_idx, int slot) { if (slot < 0 || slot >= NodePrefs::FAVOURITES_COUNT) return; - int existing = _task->findFavouriteChannelSlot(ch_idx); - if (existing >= 0 && existing != slot) _task->clearFavouriteSlot(existing); - _task->setFavouriteChannelSlot(slot, ch_idx); + favslots::pinChannel(_task->getNodePrefs(), slot, ch_idx); the_mesh.savePrefs(); char alert[24]; snprintf(alert, sizeof(alert), "Pinned to slot %d", slot + 1);