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 <noreply@anthropic.com>
This commit is contained in:
Jakub
2026-09-26 23:10:50 +02:00
co-authored by Claude Opus 5.5
parent fe92ac1153
commit eb201bc4fd
5 changed files with 68 additions and 38 deletions
+2 -1
View File
@@ -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.
@@ -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);
+18 -19
View File
@@ -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.
+43 -15
View File
@@ -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, "");
}
+4 -2
View File
@@ -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();