From eb201bc4fd7feb128c09b4fa6accf7fa3e1a1d9c Mon Sep 17 00:00:00 2001 From: Jakub <106778416+MarekZegare4@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:10:50 +0200 Subject: [PATCH] feat(ui-lvgl): favourites as a Home card, left of the main page The six slots with unread badges on a card of their own (swiped to, no clock), redrawn when messages arrive; the Favourites tile and screen are gone, showFavourites() opens the card. The page dots sit at the bottom on every card. Co-Authored-By: Claude Opus 5.5 --- docs/development/l2-roadmap.md | 3 +- .../ui-lvgl/ConversationScreen.h | 2 +- .../companion_radio/ui-lvgl/DeviceScreen.h | 37 ++++++------ examples/companion_radio/ui-lvgl/UITask.cpp | 58 ++++++++++++++----- examples/companion_radio/ui-lvgl/UITask.h | 6 +- 5 files changed, 68 insertions(+), 38 deletions(-) diff --git a/docs/development/l2-roadmap.md b/docs/development/l2-roadmap.md index bad2e352..1cb4840e 100644 --- a/docs/development/l2-roadmap.md +++ b/docs/development/l2-roadmap.md @@ -255,6 +255,7 @@ User list, second batch (2026-09-26): - [ ] Emoji, ideally the full range (glyphs could live on the SD card). - [x] Tap-to-wake toggle (Settings > Display & power > WAKE, NVS, on by default; off: only the top button wakes it). -- [ ] Favourites as their own screen in the menu rather than a tile. +- [x] Favourites as their own screen in the menu rather than a tile: a + Home card left of the main page (six slots, unread badges). - [ ] WiFi indicator icon in the status bar. diff --git a/examples/companion_radio/ui-lvgl/ConversationScreen.h b/examples/companion_radio/ui-lvgl/ConversationScreen.h index ce828b4e..db4f4780 100644 --- a/examples/companion_radio/ui-lvgl/ConversationScreen.h +++ b/examples/companion_radio/ui-lvgl/ConversationScreen.h @@ -138,7 +138,7 @@ 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 + bool picking = (_screen == SCR_CHATS || _screen == SCR_HOME) && !_nav_overlay && !locked(); // where it was tapped if (o == RoomSessions::LOGGED_IN) { showToast("Logged in", 1200); if (mine && picking) openDM(s_login_key); diff --git a/examples/companion_radio/ui-lvgl/DeviceScreen.h b/examples/companion_radio/ui-lvgl/DeviceScreen.h index fd812ce8..f94d9ded 100644 --- a/examples/companion_radio/ui-lvgl/DeviceScreen.h +++ b/examples/companion_radio/ui-lvgl/DeviceScreen.h @@ -55,7 +55,6 @@ 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 @@ -448,22 +447,15 @@ void UITask::setAccent(int idx) { pinRowRefresh(); // the page, kept at its bottom where this row is } -// ── Favourites dial ─────────────────────────────────────────────────────────── +// ── Favourites card (Home, left of the main page) ───────────────────────────── void UITask::showFavourites() { - _screen = SCR_FAVS; - buildFavourites(); + home::s_page = home::FAVS; + showHome(); } -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); +// The six slots, three to a row, into `grid` (a wrapping row). +void UITask::favGrid(lv_obj_t* grid) { 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]; @@ -471,7 +463,7 @@ void UITask::buildFavourites() { 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_size(b, w, 76); 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); @@ -484,19 +476,26 @@ void UITask::buildFavourites() { 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_align(label(b, icon, THEME_FONT_LARGE, used ? theme::ACCENT : theme::TEXT_MUTED), LV_ALIGN_TOP_MID, 0, 10); 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); + lv_obj_align(n, LV_ALIGN_BOTTOM_MID, 0, -8); 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); + lv_obj_t* hint = label(grid, "Tap to open. Hold to change or remove.", THEME_FONT_SMALL, theme::TEXT_MUTED); + lv_obj_set_width(hint, LV_PCT(100)); + lv_obj_set_style_text_align(hint, LV_TEXT_ALIGN_CENTER, 0); +} + +// A slot changed: the card redrawn if it's up. +void UITask::favRefresh() { + if (_screen == SCR_HOME && home::s_page == home::FAVS) setHomePage(home::FAVS); } void UITask::favTap(int slot) { @@ -524,7 +523,7 @@ void UITask::favAction(uint8_t act) { favslots::clear(_prefs, slot); the_mesh.savePrefs(); navClosePopup(); - buildFavourites(); + favRefresh(); } // What to put in a slot: channels, then contacts and rooms (favourites first). @@ -571,7 +570,7 @@ void UITask::favPick(int code) { else favslots::pinChannel(_prefs, s_fav_slot, (uint8_t)code); the_mesh.savePrefs(); navClosePopup(); - if (_screen == SCR_FAVS) buildFavourites(); + favRefresh(); } // "Pin" in an options popup: six slot buttons with who is in them now. diff --git a/examples/companion_radio/ui-lvgl/UITask.cpp b/examples/companion_radio/ui-lvgl/UITask.cpp index 3647494c..a6c86655 100644 --- a/examples/companion_radio/ui-lvgl/UITask.cpp +++ b/examples/companion_radio/ui-lvgl/UITask.cpp @@ -771,7 +771,7 @@ lv_obj_t* UITask::newScreen(const char* title, bool with_back) { int UITask::screenDepth(Screen s) { switch (s) { case SCR_HOME: return 0; - case SCR_CHATS: case SCR_NEARBY: case SCR_MAP: case SCR_SETTINGS: case SCR_FAVS: + case SCR_CHATS: case SCR_NEARBY: case SCR_MAP: case SCR_SETTINGS: case SCR_COMPASS: case SCR_CLOCK: case SCR_BOT: case SCR_REPEATER: case SCR_ADMIN_PICK: case SCR_DIAG: case SCR_GPS: return 1; case SCR_THREAD: case SCR_CONTACTS: case SCR_CHANNEL_EDIT: case SCR_NODE: @@ -787,7 +787,6 @@ 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_DIAG: if (_nav_overlay) navClosePopup(); else showHome(); break; case SCR_ADMIN_PICK: showHome(); break; case SCR_OTA: otaLeave(); break; @@ -860,7 +859,6 @@ 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 onOpenCompass(lv_event_t* e); // CompassScreen.h static void onOpenDiag(lv_event_t* e); // DiagScreen.h static void onOpenGps(lv_event_t* e); // GpsScreen.h @@ -870,8 +868,9 @@ static void onOpenAdminPick(lv_event_t* e); // AdminScreen.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. +// Home: the favourites card first (swiped to, left of the main page), then +// the apps, PER_PAGE to a page, further pages swiped to (dots underneath), so +// new apps don't squeeze the row. namespace home { struct App { const char* icon; const char* text; lv_event_cb_t cb; bool unread; }; static const App APPS[] = { @@ -880,23 +879,26 @@ 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_COMPASS, "Compass", onOpenCompass, false }, { UI_SYMBOL_CLOCK, "Clock", onOpenClock, false }, { LV_SYMBOL_CHARGE, "Bot", onOpenBot, false }, + { LV_SYMBOL_GPS, "GPS", onOpenGps, false }, // Page 3: the device and the network (the map's tools and Nearby's advert // are in those screens, not tiles of their own) { LV_SYMBOL_LOOP, "Repeater", onOpenRepeater, false }, { UI_SYMBOL_KEY, "Admin", onOpenAdminPick, false }, { UI_SYMBOL_CHART, "Diagnostics", onOpenDiag, false }, - { LV_SYMBOL_GPS, "GPS", onOpenGps, false }, }; static const int COUNT = sizeof(APPS) / sizeof(APPS[0]); static const int PER_PAGE = 4; -static const int PAGES = (COUNT + PER_PAGE - 1) / PER_PAGE; -static lv_obj_t* s_row = nullptr; // the current page's tiles +static const int FAVS = 0; // page 0: the favourites card +static const int MAIN = 1; // the first page of apps, where Home opens +static const int PAGES = 1 + (COUNT + PER_PAGE - 1) / PER_PAGE; +static lv_obj_t* s_row = nullptr; // the current page's tiles (or favourites) static lv_obj_t* s_dots = nullptr; -static int s_page = 0; // kept across rebuilds (the home screen is rebuilt on return) +static lv_obj_t* s_top = nullptr; // clock, date and name: hidden on the favourites card +static int s_page = MAIN; // kept across rebuilds (the home screen is rebuilt on return) +static int s_fav_sig = -1; // unread total the favourites card was drawn with // A horizontal swipe anywhere on Home turns the page (the tiles row alone is // too small a target), tracked from the touch itself: LVGL's gesture detector @@ -941,9 +943,15 @@ void UITask::buildHome() { lv_obj_t* body = newScreen(NULL, false); lv_obj_set_flex_align(body, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); - _home_clock = label(body, "--:--", THEME_FONT_CLOCK, theme::TEXT); - _home_date = label(body, "", THEME_FONT_BODY, theme::TEXT_MUTED); - lv_obj_t* name = label(body, the_mesh.getNodeName(), THEME_FONT_BODY, theme::ACCENT); + home::s_top = lv_obj_create(body); + lv_obj_remove_style_all(home::s_top); + lv_obj_remove_flag(home::s_top, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_size(home::s_top, LV_PCT(100), LV_SIZE_CONTENT); + lv_obj_set_flex_flow(home::s_top, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(home::s_top, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + _home_clock = label(home::s_top, "--:--", THEME_FONT_CLOCK, theme::TEXT); + _home_date = label(home::s_top, "", THEME_FONT_BODY, theme::TEXT_MUTED); + lv_obj_t* name = label(home::s_top, the_mesh.getNodeName(), THEME_FONT_BODY, theme::ACCENT); lv_obj_set_style_pad_bottom(name, 4, 0); lv_obj_remove_flag(body, LV_OBJ_FLAG_SCROLLABLE); // a drag is a page swipe, not a scroll @@ -953,11 +961,14 @@ void UITask::buildHome() { lv_obj_set_size(home::s_row, LV_PCT(100), LV_SIZE_CONTENT); lv_obj_set_flex_flow(home::s_row, LV_FLEX_FLOW_ROW); lv_obj_set_style_pad_column(home::s_row, theme::GAP, 0); + lv_obj_set_style_pad_row(home::s_row, theme::GAP, 0); home::s_dots = nullptr; if (home::PAGES > 1) { home::s_dots = lv_obj_create(body); lv_obj_remove_style_all(home::s_dots); + lv_obj_add_flag(home::s_dots, LV_OBJ_FLAG_IGNORE_LAYOUT); // at the bottom whichever card is up lv_obj_set_size(home::s_dots, LV_SIZE_CONTENT, 22); + lv_obj_align(home::s_dots, LV_ALIGN_BOTTOM_MID, 0, 0); lv_obj_set_flex_flow(home::s_dots, LV_FLEX_FLOW_ROW); lv_obj_set_flex_align(home::s_dots, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); lv_obj_set_style_pad_column(home::s_dots, 12, 0); @@ -999,7 +1010,16 @@ void UITask::setHomePage(int page) { home::s_page = page; lv_obj_clean(home::s_row); _home_unread = nullptr; - for (int i = page * home::PER_PAGE; i < (page + 1) * home::PER_PAGE; i++) { + bool favs = page == home::FAVS; + if (favs) lv_obj_add_flag(home::s_top, LV_OBJ_FLAG_HIDDEN); + else lv_obj_remove_flag(home::s_top, LV_OBJ_FLAG_HIDDEN); + lv_obj_set_flex_flow(home::s_row, favs ? LV_FLEX_FLOW_ROW_WRAP : LV_FLEX_FLOW_ROW); + if (favs) { + home::s_fav_sig = homeUnreadTotal(); + favGrid(home::s_row); // DeviceScreen.h + } + int first = (page - home::MAIN) * home::PER_PAGE; + for (int i = first; !favs && i < first + home::PER_PAGE; i++) { if (i < home::COUNT) { const home::App& a = home::APPS[i]; homeTile(home::s_row, a.icon, a.text, a.cb, a.unread ? &_home_unread : NULL); @@ -1016,6 +1036,10 @@ void UITask::setHomePage(int page) { refreshHome(); } +int UITask::homeUnreadTotal() { + return _core->dmUnreadTotal() + _core->history.getTotalChannelUnread() + _core->roomUnread(); +} + void UITask::refreshHome() { if (!_home_clock) return; struct tm ti; @@ -1029,8 +1053,12 @@ void UITask::refreshHome() { lv_label_set_text(_home_clock, "--:--"); lv_label_set_text(_home_date, "time not synced"); } + if (home::s_page == home::FAVS && home::s_row && homeUnreadTotal() != home::s_fav_sig) { + setHomePage(home::FAVS); // new messages: the favourites' badges redrawn + return; + } if (!_home_unread) return; - int unread = _core->dmUnreadTotal() + _core->history.getTotalChannelUnread() + _core->roomUnread(); + int unread = homeUnreadTotal(); if (unread > 0) lv_label_set_text_fmt(_home_unread, "%d", unread); else lv_label_set_text(_home_unread, ""); } diff --git a/examples/companion_radio/ui-lvgl/UITask.h b/examples/companion_radio/ui-lvgl/UITask.h index 88f799d4..f6f64f3b 100644 --- a/examples/companion_radio/ui-lvgl/UITask.h +++ b/examples/companion_radio/ui-lvgl/UITask.h @@ -255,7 +255,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, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS }; + 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_DIAG, SCR_COMPASS, SCR_SCOPES, SCR_REPEATER, SCR_MELODY, SCR_QUICK, SCR_ADMIN_PICK, SCR_OTA, SCR_STORAGE, SCR_GPS }; void buildStatusBar(); void refreshStatusBar(); @@ -342,7 +342,9 @@ private: void lockScreen(); void refreshLock(); void lockPoll(); - void buildFavourites(); + void favGrid(lv_obj_t* grid); + void favRefresh(); + int homeUnreadTotal(); void buildDiag(); void refreshDiag(); void buildCompass();